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"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import CartItemCard from "../../../features/cart/components/CartItemCard"; import CartItemCard from "../../../features/cart/components/CartItemCard";
@@ -13,7 +13,7 @@ import {
import { userStore } from "@/features/profile/userStore"; import { userStore } from "@/features/profile/userStore";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; 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() { export default function CartPage() {
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
@@ -22,8 +22,8 @@ export default function CartPage() {
const [selectedRegion, setSelectedRegion] = useState<string>(""); const [selectedRegion, setSelectedRegion] = useState<string>("");
const [selectedProvince, setSelectedProvince] = useState<number | null>(null); const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
const [note, setNote] = useState<string>(""); const [note, setNote] = useState<string>("");
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const { data: cartResponse, isLoading, isError } = useCart(); const { data: cartResponse, isLoading, isError } = useCart();
@@ -37,15 +37,43 @@ export default function CartPage() {
setIsClient(true); setIsClient(true);
}, []); }, []);
const regionGroups = provinces.reduce((acc, province) => { // Memoize region groups to prevent unnecessary recalculations
if (!acc[province.region]) { const regionGroups = useMemo(() => {
acc[province.region] = []; return provinces.reduce((acc, province) => {
} if (!acc[province.region]) {
acc[province.region].push(province); acc[province.region] = [];
return acc; }
}, {} as Record<string, typeof provinces>); 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) => { const handleDeliveryTypeChange = (type: DeliveryType) => {
setDeliveryType(type); setDeliveryType(type);
@@ -61,7 +89,6 @@ export default function CartPage() {
const selectedProvinceData = provinces.find((p) => p.id === selectedProvince); const selectedProvinceData = provinces.find((p) => p.id === selectedProvince);
if (!selectedProvinceData) return; if (!selectedProvinceData) return;
// Kullanıcı bilgilerini store'dan al
const orderData = userStore.getOrderData(); const orderData = userStore.getOrderData();
if (!orderData) { if (!orderData) {
console.error("User data not found"); console.error("User data not found");
@@ -92,7 +119,7 @@ export default function CartPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center"> <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> </div>
); );
} }
@@ -101,137 +128,80 @@ export default function CartPage() {
return ( return (
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center"> <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"> <h2 className="text-3xl md:text-4xl lg:text-5xl text-gray-400 font-semibold">
{t("emptyCart")} {t("cart_empty")}
</h2> </h2>
</div> </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 ( return (
<div className="container mx-auto px-4 py-8 min-h-screen"> <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 flex-col md:flex-row gap-6">
<div className="flex-1"> <div className="flex-1">
<Card className="p-6 rounded-xl"> <Card className="p-6 rounded-xl">
{Object.entries(itemsBySeller).map( {Object.entries(itemsBySeller).map(([sellerId, { seller, items }]) => (
([sellerId, { seller, items }]) => ( <div key={sellerId} className="mb-6">
<div key={sellerId} className="mb-6"> <p className="text-base font-semibold mb-3">{seller.name}</p>
<p className="text-base font-semibold mb-3">{seller.name}</p> <div className="space-y-4">
<div className="space-y-4"> {items.map((item) => {
{items.map((item) => { const price = parseFloat(item.product.price_amount || "0");
const price = parseFloat(item.product.price_amount || "0"); const quantity = item.product_quantity;
const quantity = item.product_quantity; const total = price * quantity;
const total = price * quantity;
return ( return (
<CartItemCard <CartItemCard
key={item.id} key={item.id}
item={{ item={{
...item, ...item,
quantity: quantity, quantity: quantity,
price: price, price: price,
total: total, total: total,
seller: seller, seller: seller,
price_formatted: `${item.product.price_amount} TMT`, price_formatted: `${item.product.price_amount} TMT`,
sub_total_formatted: `${item.product.price_amount} TMT`, sub_total_formatted: `${item.product.price_amount} TMT`,
total_formatted: `${total.toFixed(2)} TMT`, total_formatted: `${total.toFixed(2)} TMT`,
discount_formatted: "0 TMT", discount_formatted: "0 TMT",
product: { product: {
...item.product, ...item.product,
image: image:
item.product.media?.[0]?.images_800x800 || item.product.media?.[0]?.images_800x800 ||
item.product.media?.[0]?.thumbnail, item.product.media?.[0]?.thumbnail,
images: images:
item.product.media?.map( item.product.media?.map(
(m) => m.images_800x800 || m.thumbnail (m) => m.images_800x800 || m.thumbnail
) || [], ) || [],
}, },
}} }}
translations={translations} />
/> );
); })}
})}
</div>
{Object.entries(itemsBySeller).length > 1 && (
<Separator className="mt-4" />
)}
</div> </div>
) {Object.entries(itemsBySeller).length > 1 && (
)} <Separator className="mt-4" />
)}
</div>
))}
</Card> </Card>
</div> </div>
<OrderSummary <OrderSummary
order={{ order={{
id: 1, 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: { billing: {
body: [ body: [
{ {
title: t("goods"), title: t("products"),
value: `${totalAmount.toFixed(2)} TMT`, value: `${totalAmount.toFixed(2)} TMT`,
}, },
], ],
footer: { footer: {
title: t("total"), title: t("total_price"),
value: `${totalAmount.toFixed(2)} TMT`, value: `${totalAmount.toFixed(2)} TMT`,
}, },
}, },
}} }}
translations={translations}
paymentType={paymentType} paymentType={paymentType}
deliveryType={deliveryType} deliveryType={deliveryType}
selectedRegion={selectedRegion} selectedRegion={selectedRegion}

View File

@@ -4,7 +4,7 @@ import {
useAddToCart, useAddToCart,
useRemoveFromFavorites, useRemoveFromFavorites,
} from "@/lib/hooks"; } from "@/lib/hooks";
import { useState } from "react"; import { useState, useCallback, useMemo } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Heart, ShoppingCart } from "lucide-react"; import { Heart, ShoppingCart } from "lucide-react";
@@ -13,82 +13,77 @@ import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useTranslations } from "next-intl";
import type { Favorite } from "@/lib/types/api"; import type { Favorite } from "@/lib/types/api";
export default function FavoritesPage() { export default function FavoritesPage() {
const [isHovered, setIsHovered] = useState<number | null>(null); const [isHovered, setIsHovered] = useState<number | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const t = useTranslations();
const { data: favorites, isLoading, isError } = useFavorites(); const { data: favorites, isLoading, isError } = useFavorites();
const { mutate: removeFromFavorites, isPending: isRemoving } = const { mutate: removeFromFavorites, isPending: isRemoving } =
useRemoveFromFavorites(); useRemoveFromFavorites();
const { mutate: addToCart, isPending: isAddingToCart } = useAddToCart(); const { mutate: addToCart, isPending: isAddingToCart } = useAddToCart();
const t = { const handleRemoveFromFavorites = useCallback((productId: number) => {
favorites: "Избранные",
addToCart: "В корзину",
emptyFavorites: "У вас пока нет избранных товаров",
removedFromFavorites: "Товар удален из избранного",
addedToCart: "Товар добавлен в корзину",
error: "Произошла ошибка",
};
const handleRemoveFromFavorites = (productId: number) => {
removeFromFavorites(productId, { removeFromFavorites(productId, {
onSuccess: () => { onSuccess: () => {
toast({ toast({
title: t.removedFromFavorites, title: t("removed_from_favorites"),
}); });
}, },
onError: (error) => { onError: (error) => {
toast({ toast({
title: t.error, title: t("error"),
description: error.message, description: error.message,
variant: "destructive", variant: "destructive",
}); });
}, },
}); });
}; }, [removeFromFavorites, toast, t]);
const handleAddToCart = (productId: number) => { const handleAddToCart = useCallback((productId: number) => {
addToCart( addToCart(
{ productId }, { productId },
{ {
onSuccess: () => { onSuccess: () => {
toast({ toast({
title: t.addedToCart, title: t("added_to_cart"),
}); });
}, },
onError: (error) => { onError: (error) => {
toast({ toast({
title: t.error, title: t("error"),
description: error.message, description: error.message,
variant: "destructive", variant: "destructive",
}); });
}, },
} }
); );
}; }, [addToCart, toast, t]);
const loadingSkeleton = useMemo(() => (
<div className="container mx-auto px-4 py-8 min-h-screen">
<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) { if (isLoading) {
return ( return loadingSkeleton;
<div className="container mx-auto px-4 py-8 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t.favorites}</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>
);
} }
if (isError || !favorites || favorites.length === 0) { if (isError || !favorites || favorites.length === 0) {
return ( return (
<div className="container mx-auto px-4 py-8 min-h-screen"> <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]"> <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>
</div> </div>
); );
@@ -96,7 +91,7 @@ export default function FavoritesPage() {
return ( return (
<div className="container mx-auto px-4 py-8 min-h-screen"> <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"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{favorites.map((favorite: Favorite) => ( {favorites.map((favorite: Favorite) => (
<ProductCard <ProductCard
@@ -109,7 +104,6 @@ export default function FavoritesPage() {
isHovered={isHovered === favorite.product.id} isHovered={isHovered === favorite.product.id}
isRemoving={isRemoving} isRemoving={isRemoving}
isAddingToCart={isAddingToCart} isAddingToCart={isAddingToCart}
translations={t}
/> />
))} ))}
</div> </div>
@@ -142,7 +136,6 @@ interface ProductCardProps {
isHovered: boolean; isHovered: boolean;
isRemoving: boolean; isRemoving: boolean;
isAddingToCart: boolean; isAddingToCart: boolean;
translations: { addToCart: string };
} }
function ProductCard({ function ProductCard({
@@ -154,21 +147,17 @@ function ProductCard({
isHovered, isHovered,
isRemoving, isRemoving,
isAddingToCart, isAddingToCart,
translations,
}: ProductCardProps) { }: ProductCardProps) {
const t = useTranslations();
if (!product) return null; if (!product) return null;
// Получаем первое изображение из media
const imageUrl = const imageUrl =
product.media?.[0]?.images_800x800 || product.media?.[0]?.images_800x800 ||
product.media?.[0]?.thumbnail || product.media?.[0]?.thumbnail ||
"/placeholder.svg"; "/placeholder.svg";
// Форматируем цену const price = `${parseFloat(product.price_amount).toFixed(2)} TMT`;
const price = product.old_price_amount
? `${parseFloat(product.price_amount).toFixed(2)} TMT`
: `${parseFloat(product.price_amount).toFixed(2)} TMT`;
const oldPrice = product.old_price_amount const oldPrice = product.old_price_amount
? `${parseFloat(product.old_price_amount).toFixed(2)} TMT` ? `${parseFloat(product.old_price_amount).toFixed(2)} TMT`
: null; : null;
@@ -179,7 +168,7 @@ function ProductCard({
onMouseEnter={() => onHover(productId)} onMouseEnter={() => onHover(productId)}
onMouseLeave={() => onHover(null)} onMouseLeave={() => onHover(null)}
> >
<Link href={`/product/${productId|| product.slug}`} className="block"> <Link href={`/product/${productId || product.slug}`} className="block">
<div className="relative aspect-square bg-gray-50"> <div className="relative aspect-square bg-gray-50">
{/* Favorite Button */} {/* Favorite Button */}
<button <button
@@ -208,7 +197,7 @@ function ProductCard({
{product.stock === 0 && ( {product.stock === 0 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center"> <div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Badge variant="secondary" className="text-sm"> <Badge variant="secondary" className="text-sm">
Нет в наличии {t("out_of_stock")}
</Badge> </Badge>
</div> </div>
)} )}
@@ -241,10 +230,10 @@ function ProductCard({
size="sm" size="sm"
> >
<ShoppingCart className="h-4 w-4" /> <ShoppingCart className="h-4 w-4" />
{translations.addToCart} {t("add_to_cart")}
</Button> </Button>
</div> </div>
)} )}
</Card> </Card>
); );
} }

View File

@@ -1,274 +1,274 @@
"use client" // "use client"
import type React from "react" // import type React from "react"
import { useState } from "react" // import { useState } from "react"
import { Upload } from "lucide-react" // import { Upload } from "lucide-react"
import { Button } from "@/components/ui/button" // import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" // import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" // import { Label } from "@/components/ui/label"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" // import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { useOpenStore } from "@/lib/hooks" // import { useOpenStore } from "@/lib/hooks"
import { useToast } from "@/hooks/use-toast" // import { useToast } from "@/hooks/use-toast"
interface OpenStorePageProps { // interface OpenStorePageProps {
locale?: string // locale?: string
translations?: { // translations?: {
title: string // title: string
firstName: string // firstName: string
lastName: string // lastName: string
email: string // email: string
phone: string // phone: string
uploadPatent: string // uploadPatent: string
submit: string // submit: string
selectedFile: string // selectedFile: string
firstNameRequired: string // firstNameRequired: string
lastNameRequired: string // lastNameRequired: string
emailInvalid: string // emailInvalid: string
phoneInvalid: string // phoneInvalid: string
fileRequired: string // fileRequired: string
fileSizeError: string // fileSizeError: string
fileTypeError: string // fileTypeError: string
} // }
} // }
interface FormData { // interface FormData {
firstName: string // firstName: string
lastName: string // lastName: string
email: string // email: string
phone: string // phone: string
file: File | null // file: File | null
} // }
interface FormErrors { // interface FormErrors {
firstName?: string // firstName?: string
lastName?: string // lastName?: string
email?: string // email?: string
phone?: string // phone?: string
file?: string // file?: string
} // }
export default function OpenStorePage({ locale = "ru", translations }: OpenStorePageProps) { // export default function OpenStorePage({ locale = "ru", translations }: OpenStorePageProps) {
const [formData, setFormData] = useState<FormData>({ // const [formData, setFormData] = useState<FormData>({
firstName: "", // firstName: "",
lastName: "", // lastName: "",
email: "", // email: "",
phone: "+993", // phone: "+993",
file: null, // file: null,
}) // })
const [errors, setErrors] = useState<FormErrors>({}) // const [errors, setErrors] = useState<FormErrors>({})
const [fileName, setFileName] = useState("") // const [fileName, setFileName] = useState("")
const { mutate: submitOpenStore, isPending: loading } = useOpenStore() // const { mutate: submitOpenStore, isPending: loading } = useOpenStore()
const { toast } = useToast() // const { toast } = useToast()
const t = translations || { // const t = translations || {
title: "Форма подачи заявления на открытие магазина", // title: "Форма подачи заявления на открытие магазина",
firstName: "Имя", // firstName: "Имя",
lastName: "Фамилия", // lastName: "Фамилия",
email: "Email", // email: "Email",
phone: "Телефон", // phone: "Телефон",
uploadPatent: "Загрузите патент на розничную торговлю (PDF, JPG)", // uploadPatent: "Загрузите патент на розничную торговлю (PDF, JPG)",
submit: "Отправить", // submit: "Отправить",
selectedFile: "Выбранный файл", // selectedFile: "Выбранный файл",
firstNameRequired: "Имя обязательно", // firstNameRequired: "Имя обязательно",
lastNameRequired: "Фамилия обязательна", // lastNameRequired: "Фамилия обязательна",
emailInvalid: "Некорректный email", // emailInvalid: "Некорректный email",
phoneInvalid: "Некорректный номер телефона", // phoneInvalid: "Некорректный номер телефона",
fileRequired: "Патент обязателен", // fileRequired: "Патент обязателен",
fileSizeError: "Файл слишком большой (макс. 25MB)", // fileSizeError: "Файл слишком большой (макс. 25MB)",
fileTypeError: "Только PDF и JPG документы", // fileTypeError: "Только PDF и JPG документы",
} // }
const validateForm = (): boolean => { // const validateForm = (): boolean => {
const newErrors: FormErrors = {} // const newErrors: FormErrors = {}
if (!formData.firstName.trim()) { // if (!formData.firstName.trim()) {
newErrors.firstName = t.firstNameRequired // newErrors.firstName = t.firstNameRequired
} // }
if (!formData.lastName.trim()) { // if (!formData.lastName.trim()) {
newErrors.lastName = t.lastNameRequired // newErrors.lastName = t.lastNameRequired
} // }
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ // const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(formData.email)) { // if (!emailRegex.test(formData.email)) {
newErrors.email = t.emailInvalid // newErrors.email = t.emailInvalid
} // }
const phoneRegex = /^\+?[0-9]{6,15}$/ // const phoneRegex = /^\+?[0-9]{6,15}$/
if (!phoneRegex.test(formData.phone)) { // if (!phoneRegex.test(formData.phone)) {
newErrors.phone = t.phoneInvalid // newErrors.phone = t.phoneInvalid
} // }
if (!formData.file) { // if (!formData.file) {
newErrors.file = t.fileRequired // newErrors.file = t.fileRequired
} else { // } else {
const allowedTypes = ["image/jpeg", "image/jpg", "application/pdf"] // const allowedTypes = ["image/jpeg", "image/jpg", "application/pdf"]
if (!allowedTypes.includes(formData.file.type)) { // if (!allowedTypes.includes(formData.file.type)) {
newErrors.file = t.fileTypeError // newErrors.file = t.fileTypeError
} // }
if (formData.file.size > 25 * 1024 * 1024) { // if (formData.file.size > 25 * 1024 * 1024) {
newErrors.file = t.fileSizeError // newErrors.file = t.fileSizeError
} // }
} // }
setErrors(newErrors) // setErrors(newErrors)
return Object.keys(newErrors).length === 0 // return Object.keys(newErrors).length === 0
} // }
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { // const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target // const { name, value } = e.target
setFormData((prev) => ({ ...prev, [name]: value })) // setFormData((prev) => ({ ...prev, [name]: value }))
if (errors[name as keyof FormErrors]) { // if (errors[name as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [name]: undefined })) // setErrors((prev) => ({ ...prev, [name]: undefined }))
} // }
} // }
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { // const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] // const file = e.target.files?.[0]
if (file) { // if (file) {
setFormData((prev) => ({ ...prev, file })) // setFormData((prev) => ({ ...prev, file }))
setFileName(file.name) // setFileName(file.name)
if (errors.file) { // if (errors.file) {
setErrors((prev) => ({ ...prev, file: undefined })) // setErrors((prev) => ({ ...prev, file: undefined }))
} // }
} // }
} // }
const handleSubmit = (e: React.FormEvent) => { // const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() // e.preventDefault()
if (!validateForm()) return // if (!validateForm()) return
if (formData.file) { // if (formData.file) {
submitOpenStore( // submitOpenStore(
{ // {
firstName: formData.firstName, // firstName: formData.firstName,
lastName: formData.lastName, // lastName: formData.lastName,
email: formData.email, // email: formData.email,
phone: formData.phone, // phone: formData.phone,
patentFile: formData.file, // patentFile: formData.file,
}, // },
{ // {
onSuccess: () => { // onSuccess: () => {
toast({ // toast({
title: "Success", // title: "Success",
description: "Your store request has been submitted successfully", // description: "Your store request has been submitted successfully",
}) // })
setFormData({ // setFormData({
firstName: "", // firstName: "",
lastName: "", // lastName: "",
email: "", // email: "",
phone: "+993", // phone: "+993",
file: null, // file: null,
}) // })
setFileName("") // setFileName("")
}, // },
onError: (error: any) => { // onError: (error: any) => {
toast({ // toast({
title: "Error", // title: "Error",
description: error?.message || "Failed to submit store request", // description: error?.message || "Failed to submit store request",
variant: "destructive", // variant: "destructive",
}) // })
}, // },
}, // },
) // )
} // }
} // }
return ( // return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4"> // <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-lg"> // <Card className="w-full max-w-md shadow-lg">
<CardHeader> // <CardHeader>
<CardTitle className="text-2xl text-center">{t.title}</CardTitle> // <CardTitle className="text-2xl text-center">{t.title}</CardTitle>
<CardDescription className="text-center">Заполните форму для подачи заявления</CardDescription> // <CardDescription className="text-center">Заполните форму для подачи заявления</CardDescription>
</CardHeader> // </CardHeader>
<CardContent> // <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> // <form onSubmit={handleSubmit} className="space-y-4">
{/* First Name */} // {/* First Name */}
<div className="space-y-2"> // <div className="space-y-2">
<Label htmlFor="firstName">{t.firstName}</Label> // <Label htmlFor="firstName">{t.firstName}</Label>
<Input // <Input
id="firstName" // id="firstName"
name="firstName" // name="firstName"
value={formData.firstName} // value={formData.firstName}
onChange={handleInputChange} // onChange={handleInputChange}
className={errors.firstName ? "border-red-500" : ""} // className={errors.firstName ? "border-red-500" : ""}
/> // />
{errors.firstName && <p className="text-sm text-red-500">{errors.firstName}</p>} // {errors.firstName && <p className="text-sm text-red-500">{errors.firstName}</p>}
</div> // </div>
{/* Last Name */} // {/* Last Name */}
<div className="space-y-2"> // <div className="space-y-2">
<Label htmlFor="lastName">{t.lastName}</Label> // <Label htmlFor="lastName">{t.lastName}</Label>
<Input // <Input
id="lastName" // id="lastName"
name="lastName" // name="lastName"
value={formData.lastName} // value={formData.lastName}
onChange={handleInputChange} // onChange={handleInputChange}
className={errors.lastName ? "border-red-500" : ""} // className={errors.lastName ? "border-red-500" : ""}
/> // />
{errors.lastName && <p className="text-sm text-red-500">{errors.lastName}</p>} // {errors.lastName && <p className="text-sm text-red-500">{errors.lastName}</p>}
</div> // </div>
{/* Email */} // {/* Email */}
<div className="space-y-2"> // <div className="space-y-2">
<Label htmlFor="email">{t.email}</Label> // <Label htmlFor="email">{t.email}</Label>
<Input // <Input
id="email" // id="email"
name="email" // name="email"
type="email" // type="email"
value={formData.email} // value={formData.email}
onChange={handleInputChange} // onChange={handleInputChange}
className={errors.email ? "border-red-500" : ""} // className={errors.email ? "border-red-500" : ""}
/> // />
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>} // {errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
</div> // </div>
{/* Phone */} // {/* Phone */}
<div className="space-y-2"> // <div className="space-y-2">
<Label htmlFor="phone">{t.phone}</Label> // <Label htmlFor="phone">{t.phone}</Label>
<Input // <Input
id="phone" // id="phone"
name="phone" // name="phone"
value={formData.phone} // value={formData.phone}
onChange={handleInputChange} // onChange={handleInputChange}
placeholder="+99361111111" // placeholder="+99361111111"
className={errors.phone ? "border-red-500" : ""} // className={errors.phone ? "border-red-500" : ""}
/> // />
{errors.phone && <p className="text-sm text-red-500">{errors.phone}</p>} // {errors.phone && <p className="text-sm text-red-500">{errors.phone}</p>}
</div> // </div>
{/* File Upload */} // {/* File Upload */}
<div className="space-y-2"> // <div className="space-y-2">
<Label htmlFor="file">{t.uploadPatent}</Label> // <Label htmlFor="file">{t.uploadPatent}</Label>
<div className="flex flex-col gap-2"> // <div className="flex flex-col gap-2">
<Input id="file" type="file" accept=".pdf,.jpg,.jpeg" onChange={handleFileChange} className="hidden" /> // <Input id="file" type="file" accept=".pdf,.jpg,.jpeg" onChange={handleFileChange} className="hidden" />
<Button // <Button
type="button" // type="button"
variant="outline" // variant="outline"
className="w-full bg-transparent" // className="w-full bg-transparent"
onClick={() => document.getElementById("file")?.click()} // onClick={() => document.getElementById("file")?.click()}
> // >
<Upload className="mr-2 h-4 w-4" /> // <Upload className="mr-2 h-4 w-4" />
{t.uploadPatent} // {t.uploadPatent}
</Button> // </Button>
{fileName && ( // {fileName && (
<p className="text-sm text-gray-600"> // <p className="text-sm text-gray-600">
{t.selectedFile}: {fileName} // {t.selectedFile}: {fileName}
</p> // </p>
)} // )}
{errors.file && <p className="text-sm text-red-500">{errors.file}</p>} // {errors.file && <p className="text-sm text-red-500">{errors.file}</p>}
</div> // </div>
</div> // </div>
{/* Submit Button */} // {/* Submit Button */}
<Button type="submit" className="w-full" disabled={loading}> // <Button type="submit" className="w-full" disabled={loading}>
{loading ? "Загрузка..." : t.submit} // {loading ? "Загрузка..." : t.submit}
</Button> // </Button>
</form> // </form>
</CardContent> // </CardContent>
</Card> // </Card>
</div> // </div>
) // )
} // }

View File

@@ -1,15 +1,15 @@
import type { Metadata } from "next" import type { Metadata } from "next";
import { notFound } from "next/navigation" import { notFound } from "next/navigation";
import ProductPageContent from "../../../../features/products/components/ProductPageContent" import ProductPageContent from "../../../../features/products/components/ProductPageContent";
type Props = { 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> { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params const { locale, slug } = await params;
return { return {
title: `Product ${slug} | E-Commerce`, title: `Product ${slug} | E-Commerce`,
@@ -20,20 +20,20 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
title: `Product ${slug} | E-Commerce`, title: `Product ${slug} | E-Commerce`,
description: `View details for product ${slug}`, description: `View details for product ${slug}`,
}, },
} };
} }
export async function generateStaticParams() { export async function generateStaticParams() {
// Generate static params for popular products // 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) { export default async function ProductPage(props: Props) {
const params = await props.params const params = await props.params;
if (!params.slug) { 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 { useState, useEffect, useCallback } from "react";
import Link from "next/link" import Link from "next/link";
import Image from "next/image" import Image from "next/image";
import { X, Menu, Search, Store, LogOut, User as UserIcon } from "lucide-react" import { X, Menu, Search, Store, LogOut, User as UserIcon } from "lucide-react";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu";
import Logo from "@/public/logo.png" import Logo from "@/public/logo.png";
import CategoryMenu from "./ui/CategoryMenu" import CategoryMenu from "./ui/CategoryMenu";
import SearchBar from "./ui/SearchBar" import SearchBar from "./ui/SearchBar";
import AuthDialog from "./ui/AuthDialog" import AuthDialog from "./ui/AuthDialog";
import ActionButtons from "./ui/ActionButtons" import ActionButtons from "./ui/ActionButtons";
import LanguageSelector from "./ui/LanguageSelector" import LanguageSelector from "./ui/LanguageSelector";
import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth" import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
interface HeaderProps { interface HeaderProps {
locale?: string 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
}
} }
const DEFAULT_TRANSLATIONS = { export default function Header({ locale = "ru" }: HeaderProps) {
catalog: "Каталог", const [isClient, setIsClient] = useState(false);
search: "Поиск продукта", const [isCategoryOpen, setIsCategoryOpen] = useState(false);
orders: "Заказы", const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
favorites: "Избранное", const [isLoginOpen, setIsLoginOpen] = useState(false);
cart: "Корзина", const t = useTranslations();
login: "Войти",
profile: "Профиль",
openStore: "Открыть магазин",
phone: "Номер телефона",
code: "Код",
send: "Отправить",
verify: "Подтвердить",
sending: "Отправка...",
verifying: "Проверка...",
enterPhone: "Введите свой номер телефона",
weWillSendCode: "Мы вышлем вам код",
invalidPhone: "Неверный номер телефона",
invalidCode: "Неверный код",
loginSuccess: "Вход выполнен успешно",
codeSent: "Код отправлен на ваш номер",
logout: "Выйти",
loggingOut: "Выход...",
}
export default function Header({ locale = "ru", translations }: HeaderProps) { const { isAuthenticated, isLoading } = useAuthStatus();
const [isClient, setIsClient] = useState(false) const { mutate: logout, isPending: isLoggingOut } = useLogout();
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()
useEffect(() => { useEffect(() => {
setIsClient(true) setIsClient(true);
}, []) }, []);
const handleAuthClick = () => { const handleAuthClick = useCallback(() => {
if (isAuthenticated) { if (isAuthenticated) {
window.location.href = `/${locale}/me` window.location.href = `/${locale}/me`;
} else { } else {
setIsLoginOpen(true) setIsLoginOpen(true);
} }
} }, [isAuthenticated, locale]);
const handleLogout = () => { const handleLogout = useCallback(() => {
logout() logout();
} }, [logout]);
const toggleCategoryMenu = () => setIsCategoryOpen(!isCategoryOpen) const toggleCategoryMenu = useCallback(() => {
const closeCategoryMenu = () => setIsCategoryOpen(false) setIsCategoryOpen((prev) => !prev);
}, []);
if (!isClient) return null const closeCategoryMenu = useCallback(() => {
setIsCategoryOpen(false);
}, []);
if (!isClient) return null;
return ( return (
<> <>
@@ -121,7 +77,7 @@ export default function Header({ locale = "ru", translations }: HeaderProps) {
size="lg" size="lg"
> >
{isCategoryOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />} {isCategoryOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
{t.catalog} {t("common.catalog")}
</Button> </Button>
<div className="flex items-center gap-2 sm:hidden"> <div className="flex items-center gap-2 sm:hidden">
@@ -135,55 +91,25 @@ export default function Header({ locale = "ru", translations }: HeaderProps) {
<LanguageSelector /> <LanguageSelector />
</div> </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 <ActionButtons
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
onAuthClick={handleAuthClick} onAuthClick={handleAuthClick}
translations={{
profile: t.profile,
login: t.login,
orders: t.orders,
favorites: t.favorites,
cart: t.cart,
}}
/> />
</div> </div>
<Link href="/openStore"> <Link href="/openStore">
<Button variant="ghost" size="sm" className="relative flex gap-0.5 h-auto pb-2"> <Button variant="ghost" size="sm" className="relative flex gap-0.5 h-auto pb-2">
<Store className="h-5 w-5 text-gray-600" /> <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> </Button>
</Link> </Link>
</div> </div>
@@ -195,27 +121,14 @@ export default function Header({ locale = "ru", translations }: HeaderProps) {
isMobile={true} isMobile={true}
isOpen={isMobileSearchOpen} isOpen={isMobileSearchOpen}
onClose={() => setIsMobileSearchOpen(false)} onClose={() => setIsMobileSearchOpen(false)}
searchPlaceholder={t.search} searchPlaceholder={t("common.search")}
locale={locale}
/> />
<AuthDialog <AuthDialog
isOpen={isLoginOpen} isOpen={isLoginOpen}
onClose={() => setIsLoginOpen(false)} 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 { useMemo } from "react";
import Link from "next/link" import type React from "react";
import { User, Truck, Heart, ShoppingCart } from "lucide-react" import Link from "next/link";
import { Button } from "@/components/ui/button" import { User, Truck, Heart, ShoppingCart, LogOut } from "lucide-react";
import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button";
import { useCart, useFavorites, useOrders } from "@/lib/hooks" import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton" 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 { interface ActionButtonsProps {
isAuthenticated: boolean isAuthenticated: boolean;
onAuthClick: () => void onAuthClick: () => void;
translations: { isLoading?: boolean;
profile: string locale?: string;
login: string
orders: string
favorites: string
cart: string
}
} }
interface ActionButtonData { interface ActionButtonData {
icon: React.ReactNode icon: React.ReactNode;
label: string label: string;
href?: string href?: string;
onClick?: () => void onClick?: () => void;
badgeCount?: number badgeCount?: number;
isLoading?: boolean isLoading?: boolean;
} }
export default function ActionButtons({ isAuthenticated, onAuthClick, translations: t }: ActionButtonsProps) { export default function ActionButtons({
const { data: cartData, isLoading: cartLoading } = useCart() isAuthenticated,
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites() onAuthClick,
const { data: ordersData, isLoading: ordersLoading } = useOrders() isLoading: authLoading,
locale = "ru"
}: ActionButtonsProps) {
const t = useTranslations();
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const { data: cartData, isLoading: cartLoading } = useCart();
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites();
const { data: ordersData, isLoading: ordersLoading } = useOrders();
const buttons: ActionButtonData[] = [ // Calculate cart count from cart items array
{ const cartCount = useMemo(() => {
icon: <User className="h-5 w-5 text-gray-600" />, if (!cartData?.data) return 0;
label: isAuthenticated ? t.profile : t.login, return cartData.data.length;
onClick: onAuthClick, }, [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" />, icon: <Truck className="h-5 w-5 text-gray-600" />,
label: t.orders, label: t("common.orders"),
href: "/orders", href: "/orders",
badgeCount: ordersData?.length || 0, badgeCount: ordersCount,
isLoading: ordersLoading, isLoading: ordersLoading,
}, },
{ {
icon: <Heart className="h-5 w-5 text-gray-600" />, icon: <Heart className="h-5 w-5 text-gray-600" />,
label: t.favorites, label: t("common.favorites"),
href: "/favorites", href: "/favorites",
badgeCount: favoritesData?.length || 0, badgeCount: favoritesCount,
isLoading: favoritesLoading, isLoading: favoritesLoading,
}, },
{ {
icon: <ShoppingCart className="h-5 w-5 text-gray-600" />, icon: <ShoppingCart className="h-5 w-5 text-gray-600" />,
label: t.cart, label: t("common.cart"),
href: "/cart", href: "/cart",
badgeCount: cartData?.count || 0, badgeCount: cartCount,
isLoading: cartLoading, isLoading: cartLoading,
}, },
] ], [ordersCount, ordersLoading, favoritesCount, favoritesLoading, cartCount, cartLoading, t]);
return ( return (
<div className="hidden items-center gap-1 md:flex"> <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) => ( {buttons.map((button, index) => (
<ActionButton key={index} {...button} /> <ActionButton key={index} {...button} />
))} ))}
</div> </div>
) );
} }
function ActionButton({ icon, label, href, onClick, badgeCount, isLoading }: ActionButtonData) { 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}> <Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2" onClick={onClick}>
<div className="relative"> <div className="relative">
{icon} {icon}
{badgeCount !== undefined && ( {badgeCount !== undefined && badgeCount > 0 && (
<Badge <Badge
variant="destructive" variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]" 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> </div>
<span className="text-xs text-gray-700">{label}</span> <span className="text-xs text-gray-700">{label}</span>
</Button> </Button>
) );
if (href) { 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 { useState, useCallback } from "react";
import Image from "next/image" import Image from "next/image";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner" import { toast } from "sonner";
import Logo from "@/public/logo.png" import Logo from "@/public/logo.png";
import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth" import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
interface AuthDialogProps { interface AuthDialogProps {
isOpen: boolean isOpen: boolean;
onClose: () => void 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
}
} }
export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDialogProps) { export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
const [phone, setPhone] = useState("993") const [phone, setPhone] = useState("993");
const [otp, setOtp] = useState("") const [otp, setOtp] = useState("");
const [otpSent, setOtpSent] = useState(false) const [otpSent, setOtpSent] = useState(false);
const [rawPhone, setRawPhone] = useState("") const [rawPhone, setRawPhone] = useState("");
const t = useTranslations();
const { mutate: login, isPending: isLoginLoading } = useLogin() const { mutate: login, isPending: isLoginLoading } = useLogin();
const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken() const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken();
const resetDialog = () => { const resetDialog = useCallback(() => {
setOtpSent(false) setOtpSent(false);
setPhone("993") setPhone("993");
setOtp("") setOtp("");
setRawPhone("") setRawPhone("");
onClose() onClose();
} }, [onClose]);
const handleSendOtp = () => { const handleSendOtp = useCallback(() => {
const cleanPhone = phone.replace(/\D/g, "") const cleanPhone = phone.replace(/\D/g, "");
if (cleanPhone.length !== 11 || !cleanPhone.startsWith("993")) { if (cleanPhone.length !== 11 || !cleanPhone.startsWith("993")) {
toast.error(t.invalidPhone) toast.error(t("invalid_phone"));
return return;
} }
const phoneNumber = cleanPhone.substring(3) const phoneNumber = cleanPhone.substring(3);
setRawPhone(phoneNumber) setRawPhone(phoneNumber);
login( login(
{ phone_number: phoneNumber }, { phone_number: phoneNumber },
{ {
onSuccess: () => { onSuccess: () => {
toast.success(t.codeSent) toast.success(t("code_sent"));
setOtpSent(true) setOtpSent(true);
}, },
onError: (error: any) => { 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) { if (otp.length < 4) {
toast.error(t.invalidCode) toast.error(t("invalid_code"));
return return;
} }
verifyToken( verifyToken(
@@ -83,30 +71,30 @@ export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDia
}, },
{ {
onSuccess: () => { onSuccess: () => {
toast.success(t.loginSuccess) toast.success(t("login_success"));
resetDialog() resetDialog();
window.location.reload() window.location.reload();
}, },
onError: (error: any) => { 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") { if (e.key === "Enter") {
action() action();
} }
} }, []);
const formatPhoneInput = (value: string) => { const formatPhoneInput = useCallback((value: string) => {
const cleaned = value.replace(/\D/g, "") const cleaned = value.replace(/\D/g, "");
if (!cleaned.startsWith("993")) { if (!cleaned.startsWith("993")) {
return "993" return "993";
} }
return cleaned.substring(0, 11) return cleaned.substring(0, 11);
} }, []);
return ( return (
<Dialog open={isOpen} onOpenChange={resetDialog}> <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" /> <Image src={Logo} alt="Logo" fill className="object-contain" />
</div> </div>
</div> </div>
<DialogTitle className="text-2xl text-center">{t.enterPhone}</DialogTitle> <DialogTitle className="text-2xl text-center">{t("common.enterPhone")}</DialogTitle>
<p className="text-center text-sm text-gray-600">{t.weWillSendCode}</p> <p className="text-center text-sm text-gray-600">{t("common.weWillSendCode")}</p>
</DialogHeader> </DialogHeader>
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
<div> <div>
<Input <Input
type="tel" type="tel"
placeholder={t.phone} placeholder={t("common.phone")}
value={phone} value={phone}
onChange={(e) => setPhone(formatPhoneInput(e.target.value))} onChange={(e) => setPhone(formatPhoneInput(e.target.value))}
className="h-12 rounded-xl" className="h-12 rounded-xl"
@@ -133,13 +121,13 @@ export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDia
disabled={otpSent || isLoginLoading} disabled={otpSent || isLoginLoading}
maxLength={11} 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> </div>
{otpSent && ( {otpSent && (
<Input <Input
type="text" type="text"
placeholder={t.code} placeholder={t("common.code")}
value={otp} value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, "").substring(0, 6))} onChange={(e) => setOtp(e.target.value.replace(/\D/g, "").substring(0, 6))}
className="h-12 rounded-xl" className="h-12 rounded-xl"
@@ -157,15 +145,15 @@ export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDia
disabled={isLoginLoading || isVerifyLoading} disabled={isLoginLoading || isVerifyLoading}
> >
{isLoginLoading {isLoginLoading
? t.sending ? t("sending")
: isVerifyLoading : isVerifyLoading
? t.verifying ? t("verifying")
: otpSent : otpSent
? t.verify ? t("verify")
: t.send} : t("common.send")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }

View File

@@ -5,13 +5,7 @@ import Link from "next/link"
import { useCategories } from "@/lib/hooks" import { useCategories } from "@/lib/hooks"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
interface Category {
id: number
name: string
slug: string
icon_class?: string
children?: Category[]
}
interface CategoryMenuProps { interface CategoryMenuProps {
isOpen: boolean isOpen: boolean

View File

@@ -1,5 +1,7 @@
import React, { useState } from "react"; "use client";
import { Search } from "lucide-react";
import React, { useState, useEffect, useRef } from "react";
import { Search, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -8,6 +10,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useRouter } from "next/navigation";
import { useSearchProducts } from "@/features/search/hooks/useSearch";
import Image from "next/image";
interface SearchBarProps { interface SearchBarProps {
isMobile: boolean; isMobile: boolean;
@@ -15,6 +20,7 @@ interface SearchBarProps {
isOpen?: boolean; isOpen?: boolean;
onClose?: () => void; onClose?: () => void;
className?: string; className?: string;
locale?: string;
} }
export default function SearchBar({ export default function SearchBar({
@@ -23,12 +29,89 @@ export default function SearchBar({
isOpen, isOpen,
onClose, onClose,
className = "", className = "",
locale = "ru",
}: SearchBarProps) { }: SearchBarProps) {
const router = useRouter();
const [searchValue, setSearchValue] = useState(""); 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) => { const handleSearch = (value: string) => {
setSearchValue(value); 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) { if (isMobile) {
@@ -38,15 +121,19 @@ export default function SearchBar({
<DialogHeader> <DialogHeader>
<DialogTitle>{searchPlaceholder}</DialogTitle> <DialogTitle>{searchPlaceholder}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="relative"> <div className="relative" ref={searchRef}>
<Input <Input
type="search" type="text"
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={searchValue} value={searchValue}
onChange={(e) => handleSearch(e.target.value)} 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]" className="h-10 rounded-xl focus:border-[#005bff] focus-visible:border-[#005bff] focus-visible:ring-0 active:border-[#005bff]"
autoFocus 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> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -54,15 +141,18 @@ export default function SearchBar({
} }
return ( return (
<div className={`bg-[#005bff] rounded-xl ${className}`}> <div className={`bg-[#005bff] rounded-xl flex items-center relative ${className}`} ref={searchRef}>
<div className="w-full"> <div className="w-full relative">
<Input <Input
type="search" type="text"
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={searchValue} value={searchValue}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
className="border-[#005bff] w-full rounded-xl border-2 focus-visible:ring-0 bg-white px-2" 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> </div>
<Button <Button
size="icon" size="icon"
@@ -70,6 +160,7 @@ export default function SearchBar({
> >
<Search className="h-5 w-5" /> <Search className="h-5 w-5" />
</Button> </Button>
<SearchResults />
</div> </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,176 +1,413 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef, useCallback } from "react"
import Image from "next/image" 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 { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks" 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 { interface CartItemCardProps {
item: CartItem item: CartItem
translations: CartTranslations
onUpdate?: () => void 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 [localQuantity, setLocalQuantity] = useState(item.quantity)
const [pendingQuantity, setPendingQuantity] = useState(item.quantity)
const [isLoading, setIsLoading] = useState(false) // Sync State
const updateTimeoutRef = useRef<NodeJS.Timeout>() 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: updateQuantity } = useUpdateCartItemQuantity()
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart() const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart()
// Get available stock
const availableStock = item.product.stock || 0
// Initialize from server state
useEffect(() => { useEffect(() => {
setLocalQuantity(item.quantity) setLocalQuantity(item.quantity)
setPendingQuantity(item.quantity)
}, [item.quantity]) }, [item.quantity])
useEffect(() => { // Save to sessionStorage
if (pendingQuantity === item.quantity) return const savePendingUpdate = useCallback((quantity: number) => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY)
const pending: Record<number, PendingUpdate> = stored ? JSON.parse(stored) : {}
pending[item.product_id] = {
quantity,
timestamp: Date.now(),
retryCount: retryCountRef.current
}
sessionStorage.setItem(PENDING_CART_UPDATES_KEY, JSON.stringify(pending))
} catch (error) {
console.error('Failed to save pending update:', error)
}
}, [item.product_id])
if (updateTimeoutRef.current) { // Remove from sessionStorage
clearTimeout(updateTimeoutRef.current) 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
} }
updateTimeoutRef.current = setTimeout(() => { const delay = Math.min(1000 * Math.pow(2, retryCount), 16000) // Max 16s
setIsLoading(true) retryCountRef.current++
retryTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(quantity)
}, delay)
}, [])
if (pendingQuantity <= 0) { // Update ref
removeItem(item.product_id, { retrySyncRef.current = retrySync
onSuccess: () => onUpdate?.(),
onError: () => { // Sync to server
setLocalQuantity(item.quantity) const syncToServer = useCallback((quantity: number) => {
setPendingQuantity(item.quantity) // If already syncing, queue this update
}, if (isRequestInFlightRef.current) {
onSettled: () => setIsLoading(false), pendingQuantityRef.current = quantity
}) return
} else { }
updateQuantity(
{ productId: item.product_id, quantity: pendingQuantity }, // Mark as syncing
{ isRequestInFlightRef.current = true
onSuccess: () => onUpdate?.(), setIsSyncing(true)
onError: () => { setSyncError(false)
setLocalQuantity(item.quantity)
setPendingQuantity(item.quantity) if (quantity <= 0) {
}, removeItem(item.product_id, {
onSettled: () => setIsLoading(false), onSuccess: () => {
isRequestInFlightRef.current = false
setIsSyncing(false)
retryCountRef.current = 0
clearPendingUpdate()
onUpdate?.()
// Process queued update if any
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current
pendingQuantityRef.current = null
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100)
} }
) },
onError: (error) => {
console.error('Remove failed:', error)
isRequestInFlightRef.current = false
retrySyncRef.current?.(quantity)
}
})
} else {
updateQuantity(
{ productId: item.product_id, quantity },
{
onSuccess: () => {
isRequestInFlightRef.current = false
setIsSyncing(false)
retryCountRef.current = 0
clearPendingUpdate()
onUpdate?.()
// Process queued update if any
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current
pendingQuantityRef.current = null
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100)
}
},
onError: (error) => {
console.error('Update failed:', error)
isRequestInFlightRef.current = false
// Rollback on error after retries exhausted
if (retryCountRef.current >= 3) {
setLocalQuantity(item.quantity)
clearPendingUpdate()
}
retrySyncRef.current?.(quantity)
}
}
)
}
}, [item.product_id, item.quantity, updateQuantity, removeItem, onUpdate, clearPendingUpdate])
// Update ref
syncToServerRef.current = syncToServer
// Load pending updates from sessionStorage on mount
useEffect(() => {
const loadPendingUpdates = () => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY)
if (stored) {
const pending: Record<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)
} }
}, 300) }
loadPendingUpdates()
}, [item.product_id, item.quantity])
// Debounced sync
useEffect(() => {
// Clear existing timers
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
// If local quantity matches server, no sync needed
if (localQuantity === item.quantity) {
return
}
// Save to sessionStorage immediately
savePendingUpdate(localQuantity)
// Debounce the API call
debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity)
}, 800)
return () => { return () => {
if (updateTimeoutRef.current) { if (debounceTimerRef.current) {
clearTimeout(updateTimeoutRef.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) => { const handleQuantityIncrease = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (isLoading) return
// Check stock limit
const newQuantity = localQuantity + 1 if (localQuantity >= availableStock) {
setLocalQuantity(newQuantity) setShowStockModal(true)
setPendingQuantity(newQuantity) return
}
// Optimistic update (instant UI feedback)
setLocalQuantity(prev => prev + 1)
} }
const handleQuantityDecrease = (e: React.MouseEvent) => { const handleQuantityDecrease = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
if (isLoading) return
if (localQuantity <= 1) {
const newQuantity = localQuantity - 1
if (newQuantity < 1) {
handleDelete() handleDelete()
return return
} }
setLocalQuantity(newQuantity) // Optimistic update (instant UI feedback)
setPendingQuantity(newQuantity) setLocalQuantity(prev => prev - 1)
} }
const handleDelete = () => { const handleDelete = () => {
setIsLoading(true) setLocalQuantity(0)
removeItem(item.product_id, { clearPendingUpdate()
onSuccess: () => onUpdate?.(),
onSettled: () => setIsLoading(false),
})
} }
const getImageSrc = () => { const getImageSrc = () => {
if (item.product.image) return item.product.image 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 "/placeholder.svg"
} }
return ( return (
<Card className="p-4 shadow-none border"> <>
<div className="flex flex-col sm:flex-row gap-4"> <Card className="p-4 shadow-none border">
<div className="flex gap-4 flex-1"> <div className="flex flex-col sm:flex-row gap-4">
<div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden flex-shrink-0"> <div className="flex gap-4 flex-1">
<Image src={getImageSrc()} alt={item.product.name} fill className="object-contain" /> <div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden flex-shrink-0">
</div> <Image src={getImageSrc()} alt={item.product.name} fill className="object-contain" />
<div className="flex flex-col gap-2"> </div>
<h3 className="font-semibold text-base">{item.product.name}</h3> <div className="flex flex-col gap-2">
<p className="text-sm text-gray-600">{item.seller?.name || "Store"}</p> <h3 className="font-semibold text-base">{item.product.name}</h3>
<Button <p className="text-sm text-gray-600">{item.seller?.name || "Store"}</p>
variant="ghost" {availableStock <= 5 && (
size="sm" <p className="text-xs text-orange-600 font-medium">
onClick={handleDelete} {t("only_left", { count: availableStock })}
disabled={isRemoving || isLoading} </p>
className="w-fit p-0 h-auto hover:bg-transparent hover:text-red-500" )}
> <Button
<Trash2 className="h-5 w-5" /> variant="ghost"
</Button> size="sm"
</div> onClick={handleDelete}
</div> disabled={isRemoving}
className="w-fit p-0 h-auto hover:bg-transparent hover:text-red-500"
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between"> >
<div className="space-y-1"> <Trash2 className="h-5 w-5" />
<p className="text-sm font-semibold"> </Button>
{t.pricePerUnit} <span className="text-primary">{item.price_formatted}</span>
</p>
<p className="text-sm font-semibold">
{t.additionalPrice} {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>
)}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{t.totalPrice}</span>
<span className="bg-green-500 text-white px-3 py-1 rounded-xl font-semibold text-base">
{item.total_formatted}
</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
<Button <div className="space-y-1">
variant="outline" <p className="text-sm font-semibold">
size="icon" {t("unit_price")} <span className="text-primary">{item.price_formatted}</span>
onClick={handleQuantityDecrease} </p>
disabled={isLoading || isRemoving} <p className="text-sm font-semibold">
className="rounded-xl bg-blue-50" {t("extra_price")} {item.sub_total_formatted}
> </p>
<Minus className="h-4 w-4" /> {item.discount_formatted && item.discount_formatted !== "0 TMT" && (
</Button> <p className="text-sm font-semibold">{t("discount")} {item.discount_formatted}</p>
<div className="w-12 text-center font-semibold">{localQuantity}</div> )}
<Button <div className="flex items-center gap-2">
variant="outline" <span className="text-sm font-semibold">{t("total_price")}</span>
size="icon" <span className="bg-green-500 text-white px-3 py-1 rounded-xl font-semibold text-base">
onClick={handleQuantityIncrease} {(parseFloat(item.product.price_amount || "0") * localQuantity).toFixed(2)} TMT
disabled={isLoading || isRemoving} </span>
className="rounded-xl bg-blue-50" </div>
> </div>
<Plus className="h-4 w-4" />
</Button> <div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handleQuantityDecrease}
className={`rounded-xl bg-blue-50 ${isSyncing ? 'opacity-70' : ''}`}
>
<Minus className="h-4 w-4" />
</Button>
<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={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>
</div>
</div> </div>
</div> </div>
</div> </Card>
</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" "use client"
import { Truck, Warehouse } from "lucide-react" import { Truck, Warehouse } from "lucide-react"
import { Card } from "@/components/ui/card" 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 { interface DeliveryTypeSelectorProps {
selectedType: DeliveryType selectedType: DeliveryType
onSelect: (type: DeliveryType) => void onSelect: (type: DeliveryType) => void
translations: CartTranslations
} }
export default function DeliveryTypeSelector({ export default function DeliveryTypeSelector({
selectedType, selectedType,
onSelect, onSelect,
translations: t,
}: DeliveryTypeSelectorProps) { }: DeliveryTypeSelectorProps) {
const t = useTranslations()
const deliveryOptions: { const deliveryOptions: {
type: DeliveryType type: DeliveryType
label: string label: string
icon: typeof Truck icon: typeof Truck
}[] = [ }[] = [
{ type: "SELECTED_DELIVERY", label: t.delivery, icon: Truck }, { type: "SELECTED_DELIVERY", label: t("delivery"), icon: Truck },
{ type: "PICK_UP", label: t.pickup, icon: Warehouse }, { type: "PICK_UP", label: t("pickup"), icon: Warehouse },
] ]
return ( return (
<div className="mb-6"> <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"> <div className="flex gap-2">
{deliveryOptions.map(({ type, label, icon: Icon }) => ( {deliveryOptions.map(({ type, label, icon: Icon }) => (
<Card <Card

View File

@@ -1,37 +1,58 @@
"use client" "use client";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import {
import DeliveryTypeSelector from "./DeliveryTypeSelector" Select,
import type { Order, Province, DeliveryType, CartTranslations, PaymentType } from "../types" 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 { interface OrderSummaryProps {
order: Order order: {
translations: CartTranslations id: number;
paymentType: PaymentType | null billing: OrderBilling;
deliveryType: DeliveryType };
selectedRegion: string paymentType: PaymentType | null;
selectedProvince: number | null deliveryType: DeliveryType;
note: string selectedRegion: string;
regionGroups: Record<string, Province[]> selectedProvince: number | null;
availableRegions: string[] note: string;
paymentTypes: PaymentType[] regionGroups: Record<string, Province[]>;
onPaymentTypeChange: (type: PaymentType) => void availableRegions: string[];
onDeliveryTypeChange: (type: DeliveryType) => void paymentTypes: PaymentType[];
onRegionChange: (regionCode: string) => void onPaymentTypeChange: (type: PaymentType) => void;
onProvinceChange: (provinceId: number) => void onDeliveryTypeChange: (type: DeliveryType) => void;
onNoteChange: (note: string) => void onRegionChange: (regionCode: string) => void;
onCompleteOrder: () => void onProvinceChange: (provinceId: number) => void;
isLoading: boolean onNoteChange: (note: string) => void;
onCompleteOrder: () => void;
isLoading: boolean;
} }
export default function OrderSummary({ export default function OrderSummary({
order, order,
translations: t,
paymentType, paymentType,
deliveryType, deliveryType,
selectedRegion, selectedRegion,
@@ -48,29 +69,35 @@ export default function OrderSummary({
onCompleteOrder, onCompleteOrder,
isLoading, isLoading,
}: OrderSummaryProps) { }: OrderSummaryProps) {
const provincesForSelectedRegion = selectedRegion ? regionGroups[selectedRegion] || [] : [] const t = useTranslations();
const isFormValid = selectedRegion && selectedProvince && paymentType
const provincesForSelectedRegion = selectedRegion
? regionGroups[selectedRegion] || []
: [];
const isFormValid = selectedRegion && selectedProvince && paymentType;
return ( return (
<Card className="w-full md:w-[380px] p-6 rounded-xl h-fit sticky top-20"> <Card className="w-full md:w-[380px] p-6 rounded-xl h-fit sticky top-20">
{/* Payment Type */} {/* Payment Type */}
<div className="mb-6"> <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"> <div className="flex gap-2">
{paymentTypes.map((type) => ( {paymentTypes.map((type) => (
<Card <Card
key={type.id} key={type.id}
className={`flex-1 cursor-pointer transition-all ${ className={`flex-1 cursor-pointer transition-all ${
paymentType?.id === type.id paymentType?.id === type.id
? "border-2 border-[#005bff] bg-blue-50" ? "border-2 border-[#005bff] bg-blue-50"
: "border-2 border-gray-200" : "border-2 border-gray-200"
}`} }`}
onClick={() => onPaymentTypeChange(type)} onClick={() => onPaymentTypeChange(type)}
> >
<div className="flex flex-col items-center justify-center p-4 gap-2"> <div className="flex flex-col items-center justify-center p-4 gap-2">
<span className={`text-xs font-medium ${ <span
paymentType?.id === type.id ? "text-[#005bff]" : "" className={`text-xs font-medium ${
}`}> paymentType?.id === type.id ? "text-[#005bff]" : ""
}`}
>
{type.name} {type.name}
</span> </span>
</div> </div>
@@ -80,21 +107,22 @@ export default function OrderSummary({
</div> </div>
{/* Delivery Type */} {/* Delivery Type */}
<DeliveryTypeSelector <DeliveryTypeSelector
selectedType={deliveryType} selectedType={deliveryType}
onSelect={onDeliveryTypeChange} onSelect={onDeliveryTypeChange}
translations={t}
/> />
{/* Region Selection */} {/* Region Selection */}
<div className="mb-6"> <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">
<RadioGroup {t("choose_region")}
value={selectedRegion} </Label>
<RadioGroup
value={selectedRegion}
onValueChange={(value) => { onValueChange={(value) => {
onRegionChange(value) onRegionChange(value);
onProvinceChange(null as any) onProvinceChange(null as any);
}} }}
className="flex flex-wrap gap-4" className="flex flex-wrap gap-4"
> >
{availableRegions.map((regionCode) => ( {availableRegions.map((regionCode) => (
@@ -104,7 +132,10 @@ export default function OrderSummary({
id={`region-${regionCode}`} id={`region-${regionCode}`}
className="border-2 border-gray-400 data-[state=checked]:border-[#005bff] data-[state=checked]:bg-white" 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} {regionCode}
</Label> </Label>
</div> </div>
@@ -115,13 +146,15 @@ export default function OrderSummary({
{/* Province Selection */} {/* Province Selection */}
{selectedRegion && provincesForSelectedRegion.length > 0 && ( {selectedRegion && provincesForSelectedRegion.length > 0 && (
<div className="mb-6"> <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">
<Select {t("choose_address")}
value={selectedProvince?.toString() || ""} </Label>
<Select
value={selectedProvince?.toString() || ""}
onValueChange={(value) => onProvinceChange(parseInt(value))} onValueChange={(value) => onProvinceChange(parseInt(value))}
> >
<SelectTrigger className="rounded-xl"> <SelectTrigger className="rounded-xl">
<SelectValue placeholder={t.selectAddress} /> <SelectValue placeholder={t("choose_address")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{provincesForSelectedRegion.map((province) => ( {provincesForSelectedRegion.map((province) => (
@@ -136,20 +169,23 @@ export default function OrderSummary({
{/* Note */} {/* Note */}
<div className="mb-6"> <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 <Textarea
value={note} value={note}
onChange={(e) => onNoteChange(e.target.value)} onChange={(e) => onNoteChange(e.target.value)}
className="rounded-xl resize-none" className="rounded-xl resize-none"
rows={3} rows={3}
placeholder={t.note} placeholder={t("note")}
/> />
</div> </div>
{/* Billing */} {/* Billing */}
<div className="space-y-2 mb-4"> <div className="space-y-2 mb-4">
{order.billing.body.map((item, index) => ( {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.title}:</span>
<span>{item.value}</span> <span>{item.value}</span>
</div> </div>
@@ -159,8 +195,12 @@ export default function OrderSummary({
<Separator className="my-4" /> <Separator className="my-4" />
<div className="flex justify-between items-center mb-6"> <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-semibold">
<span className="text-lg font-bold text-green-600">{order.billing.footer.value}</span> {order.billing.footer.title}:
</span>
<span className="text-lg font-bold text-green-600">
{order.billing.footer.value}
</span>
</div> </div>
<Button <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" className="w-full rounded-xl bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50"
size="lg" size="lg"
> >
{isLoading ? `${t.placeOrder}...` : t.placeOrder} {isLoading ? `${t("order")}...` : t("order")}
</Button> </Button>
</Card> </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

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

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

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 ( return (
<section className="bg-white rounded-2xl shadow-sm p-6"> <section className="bg-white rounded-2xl shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">{title}</h2> <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) => ( {categories?.map((cat) => (
<Link <Link
key={cat.id} key={cat.id}

View File

@@ -1,6 +1,6 @@
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import ProductGridSkeleton from "./ProductGridSkeleton" import ProductGridSkeleton from "./ProductGridSkeleton"
import CategorySkeleton from "./CategorySkeleton" import CategorySkeleton from "../../category/components/CategorySkeleton"
export default function HomeSkeleton() { export default function HomeSkeleton() {
return ( 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" /> <ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
</div> </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) => { {displayProducts.map((product) => {
// Extract first media image or use placeholder // Extract first media image or use placeholder
const firstImage = const firstImage =

View File

@@ -101,7 +101,7 @@ export function useCollectionHasProducts(
queryFn: async () => { queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Product>>( const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`, `/collections/${collectionId}/products`,
{ params: { perPage: 1 } } { params: { perPage: 20 } }
); );
return { return {
hasProducts: response.data.data && response.data.data.length > 0, 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"; "use client";
import { useState } from "react"; import { useState, useCallback, useMemo } from "react";
import Image from "next/image"; import Image from "next/image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -17,130 +17,111 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { useOrders, useCancelOrder } from "@/lib/hooks"; 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 [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
const [orderToCancel, setOrderToCancel] = useState<Order | null>(null); const [orderToCancel, setOrderToCancel] = useState<Order | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const t = useTranslations();
const { data: orders, isLoading, isError, error } = useOrders(); const { data: orders, isLoading, isError, error } = useOrders();
const { mutate: cancelOrder, isPending: isCancellingOrder } = useCancelOrder(); const { mutate: cancelOrder, isPending: isCancellingOrder } = useCancelOrder();
const t = { const handleCancelOrder = useCallback((order: Order) => {
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) => {
setOrderToCancel(order); setOrderToCancel(order);
setIsCancelDialogOpen(true); setIsCancelDialogOpen(true);
}; }, []);
const confirmCancelOrder = () => { const confirmCancelOrder = useCallback(() => {
if (!orderToCancel) return; if (!orderToCancel) return;
cancelOrder(orderToCancel.id, { cancelOrder(orderToCancel.id, {
onSuccess: () => { onSuccess: () => {
toast({ toast({
title: t.orderCancelled, title: t("order_cancelled"),
description: t.orderCancelledDescription, description: t("order_cancelled_description"),
}); });
setIsCancelDialogOpen(false); setIsCancelDialogOpen(false);
setOrderToCancel(null); setOrderToCancel(null);
}, },
onError: (error: any) => { onError: (error: any) => {
toast({ toast({
title: t.error, title: t("error"),
description: error.message || "Не удалось отменить заказ", description: error.message || t("cancel_order_failed"),
variant: "destructive", variant: "destructive",
}); });
}, },
}); });
}; }, [orderToCancel, cancelOrder, toast, t]);
const getStatusBadge = (status: string) => { const getStatusBadge = useCallback((status: string) => {
const lowerStatus = status.toLowerCase(); 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>; 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>; 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>; 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>; 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 variant="destructive">{status}</Badge>;
} }
return <Badge>{status}</Badge>; return <Badge>{status}</Badge>;
}; }, []);
const isActiveOrder = (status: string) => { const isActiveOrder = useCallback((status: string) => {
const lower = status.toLowerCase(); const lower = status.toLowerCase();
return lower.includes("ожидается") || lower.includes("обработка") || lower.includes("отправлен") || 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 activeOrders = useMemo(() => orders?.filter((o) => isActiveOrder(o.status)) || [], [orders, isActiveOrder]);
const completedOrders = orders?.filter((o) => !isActiveOrder(o.status)) || []; 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 order.orderItems.reduce((sum, item) => {
return sum + (parseFloat(item.unit_price_amount) * item.quantity); return sum + (parseFloat(item.unit_price_amount) * item.quantity);
}, 0); }, 0);
}; }, []);
if (isLoading) { const loadingSkeleton = useMemo(() => (
return ( <div className="container mx-auto p-4 min-h-screen">
<div className="container mx-auto p-4 min-h-screen"> <h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1>
<h1 className="text-3xl font-bold mb-6">{t.myOrders}</h1> <div className="space-y-4">
<div className="space-y-4"> <Skeleton className="h-10 w-40" />
<Skeleton className="h-10 w-40" /> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {Array.from({ length: 6 }).map((_, i) => (
{Array.from({ length: 6 }).map((_, i) => ( <Skeleton key={i} className="h-64 rounded-lg" />
<Skeleton key={i} className="h-64 rounded-lg" /> ))}
))}
</div>
</div> </div>
</div> </div>
); </div>
), [t]);
if (isLoading) {
return loadingSkeleton;
} }
if (isError) { if (isError) {
return ( return (
<div className="container mx-auto p-4 min-h-screen"> <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"> <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>
</div> </div>
); );
@@ -149,9 +130,9 @@ export default function OrdersPageClient() {
if (!orders || orders.length === 0) { if (!orders || orders.length === 0) {
return ( return (
<div className="container mx-auto p-4 min-h-screen"> <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]"> <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>
</div> </div>
); );
@@ -159,22 +140,22 @@ export default function OrdersPageClient() {
return ( return (
<div className="container mx-auto p-4 min-h-screen"> <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"> <Tabs defaultValue="active" className="w-full">
<TabsList className="mb-6"> <TabsList className="mb-6">
<TabsTrigger value="active"> <TabsTrigger value="active">
{t.activeOrders} ({activeOrders.length}) {t("active_orders")} ({activeOrders.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="completed"> <TabsTrigger value="completed">
{t.completedOrders} ({completedOrders.length}) {t("completed_orders")} ({completedOrders.length})
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="active"> <TabsContent value="active">
{activeOrders.length === 0 ? ( {activeOrders.length === 0 ? (
<div className="flex items-center justify-center min-h-[40vh]"> <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>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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} isCancelling={isCancellingOrder}
getStatusBadge={getStatusBadge} getStatusBadge={getStatusBadge}
calculateTotal={calculateTotal} calculateTotal={calculateTotal}
translations={t}
showCancelButton showCancelButton
/> />
))} ))}
@@ -197,7 +177,7 @@ export default function OrdersPageClient() {
<TabsContent value="completed"> <TabsContent value="completed">
{completedOrders.length === 0 ? ( {completedOrders.length === 0 ? (
<div className="flex items-center justify-center min-h-[40vh]"> <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>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <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} isCancelling={isCancellingOrder}
getStatusBadge={getStatusBadge} getStatusBadge={getStatusBadge}
calculateTotal={calculateTotal} calculateTotal={calculateTotal}
translations={t}
showCancelButton={false} showCancelButton={false}
/> />
))} ))}
@@ -222,9 +201,9 @@ export default function OrdersPageClient() {
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{t.cancelOrder} #{orderToCancel?.id} {t("cancel_order")} #{orderToCancel?.id}
</DialogTitle> </DialogTitle>
<DialogDescription>{t.cancelConfirmation}</DialogDescription> <DialogDescription>{t("cancel_confirmation")}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button <Button
@@ -232,10 +211,10 @@ export default function OrdersPageClient() {
onClick={() => setIsCancelDialogOpen(false)} onClick={() => setIsCancelDialogOpen(false)}
disabled={isCancellingOrder} disabled={isCancellingOrder}
> >
{t.keepOrder} {t("keep_order")}
</Button> </Button>
<Button variant="destructive" onClick={confirmCancelOrder} disabled={isCancellingOrder}> <Button variant="destructive" onClick={confirmCancelOrder} disabled={isCancellingOrder}>
{isCancellingOrder ? t.cancelling : t.cancelOrder} {isCancellingOrder ? t("cancelling") : t("cancel_order")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -250,7 +229,6 @@ interface OrderCardProps {
isCancelling: boolean; isCancelling: boolean;
getStatusBadge: (status: string) => React.ReactNode; getStatusBadge: (status: string) => React.ReactNode;
calculateTotal: (order: Order) => number; calculateTotal: (order: Order) => number;
translations: any;
showCancelButton: boolean; showCancelButton: boolean;
} }
@@ -260,34 +238,34 @@ function OrderCard({
isCancelling, isCancelling,
getStatusBadge, getStatusBadge,
calculateTotal, calculateTotal,
translations: t,
showCancelButton, showCancelButton,
}: OrderCardProps) { }: OrderCardProps) {
const total = calculateTotal(order); const t = useTranslations();
const total = useMemo(() => calculateTotal(order), [calculateTotal, order]);
return ( return (
<Card className="p-4 flex flex-col justify-between"> <Card className="p-4 flex flex-col justify-between">
<div> <div>
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-semibold"> <h3 className="text-lg font-semibold">
{t.orderNumber}{order.id} {t("order_number")}{order.id}
</h3> </h3>
{getStatusBadge(order.status)} {getStatusBadge(order.status)}
</div> </div>
<div className="mb-3 space-y-1 text-sm"> <div className="mb-3 space-y-1 text-sm">
<p className="text-gray-600"> <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>
<p className="text-gray-600"> <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()} {new Date(order.delivery_at).toLocaleDateString()}
</p> </p>
<p className="text-gray-600"> <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>
<p className="text-gray-600"> <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> </p>
</div> </div>
@@ -304,7 +282,7 @@ function OrderCard({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium line-clamp-2">{item.product.name}</p> <p className="text-sm font-medium line-clamp-2">{item.product.name}</p>
<p className="text-xs text-gray-500"> <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> </p>
</div> </div>
</div> </div>
@@ -313,7 +291,7 @@ function OrderCard({
<div className="border-t pt-3"> <div className="border-t pt-3">
<div className="flex justify-between font-semibold"> <div className="flex justify-between font-semibold">
<span>{t.total}</span> <span>{t("total_price")}</span>
<span>{total.toFixed(2)} TMT</span> <span>{total.toFixed(2)} TMT</span>
</div> </div>
</div> </div>
@@ -327,7 +305,7 @@ function OrderCard({
disabled={isCancelling} disabled={isCancelling}
className="w-full" className="w-full"
> >
{t.cancelOrder} {t("cancel_order")}
</Button> </Button>
</div> </div>
)} )}

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api"; 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 }) { export function useOrders(options?: { page?: number; perPage?: number }) {
return useQuery<Order[]>({ 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,401 +1,625 @@
"use client" "use client";
import { useState } from "react" import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import Image from "next/image" import Image from "next/image";
import Link from "next/link" import Link from "next/link";
import { Minus, Plus, Heart, ShoppingCart, Store } from "lucide-react" import { Minus, Plus, Heart, ShoppingCart, Store, Loader2, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar" import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton";
import { useProductsBySlug } from "@/features/products/hooks/useProducts" import {
import { useAddToCart, useUpdateCartItemQuantity, useCart } from "@/features/cart/hooks/useCart" Dialog,
import { toast } from "sonner" 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 { interface ProductDetailProps {
slug: string slug: string;
} }
const ProductPageContent = ({ slug }: ProductDetailProps) => { const PENDING_PRODUCT_UPDATES_KEY = 'pendingProductUpdates';
const [selectedImage, setSelectedImage] = useState(0)
const [quantity, setQuantity] = useState(1)
const [isFavorite, setIsFavorite] = useState(false)
// Get product data interface PendingUpdate {
const { data: product, isLoading: productLoading, error } = useProductsBySlug(slug) quantity: number;
timestamp: number;
retryCount: number;
}
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);
// Get cart data to check if product is already in cart const t = useTranslations();
const { data: cartData } = useCart()
// Cart mutations
const addToCartMutation = useAddToCart()
const updateCartMutation = useUpdateCartItemQuantity()
const t = { const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
addToCart: "Sebede goş", const isRequestInFlightRef = useRef(false);
goToCart: "Sebede git", const pendingQuantityRef = useRef<number | null>(null);
price: "Bahasy:", const retryCountRef = useRef(0);
aboutProduct: "Haryt barada", const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
brand: "Marka", const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
stock: "Mukdary", const retrySyncRef = useRef<((quantity: number) => void) | null>(null);
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",
}
// Check if product is in cart const { data: product, isLoading: productLoading, error } = useProductsBySlug(slug);
const cartItem = cartData?.data?.find((item: any) => item.product?.id === product?.id) const { data: cartData, refetch: refetchCart } = useCart();
const isInCart = !!cartItem const addToCartMutation = useAddToCart();
const updateCartMutation = useUpdateCartItemQuantity();
const handleAddToCart = async () => { const cartItem = useMemo(() =>
if (!product?.id) return 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 { try {
await addToCartMutation.mutateAsync({ await addToCartMutation.mutateAsync({
productId: product.id, productId: product.id,
quantity: quantity, quantity: localQuantity,
}) });
toast.success(t.addedToCart, { // Refetch cart immediately to update isInCart state
description: `${product.name} sebede goşuldy`, await refetchCart();
})
setIsSyncing(false);
toast.success(t("added_to_cart"), {
description: `${product.name} ${t("added_to_cart_description")}`,
});
} catch (error) { } catch (error) {
console.error("Add to cart error:", error) console.error("Add to cart error:", error);
toast.error(t.error, { setIsSyncing(false);
description: "Haryt sebede goşup bolmady", toast.error(t("error"), {
}) description: t("add_to_cart_failed"),
});
} }
} }, [product, localQuantity, addToCartMutation, refetchCart, t]);
const handleQuantityChange = async (newQuantity: number) => { const handleQuantityIncrease = useCallback(() => {
if (newQuantity < 1 || !product?.id) return if (localQuantity >= availableStock) {
if (newQuantity > product.stock) return setShowStockModal(true);
return;
setQuantity(newQuantity)
// If product is already in cart, update it
if (isInCart) {
try {
await updateCartMutation.mutateAsync({
productId: product.id,
quantity: newQuantity,
})
toast.success(t.updatedCart, {
description: `Mukdar: ${newQuantity}`,
})
} catch (error) {
console.error("Update cart error:", error)
toast.error(t.error, {
description: "Mukdar täzelenip bolmady",
})
}
} }
}
setLocalQuantity(prev => prev + 1);
}, [localQuantity, availableStock]);
const handleToggleFavorite = () => { const handleQuantityDecrease = useCallback(() => {
setIsFavorite(!isFavorite) if (localQuantity <= 1) return;
// TODO: Implement favorites API
} setLocalQuantity(prev => prev - 1);
}, [localQuantity]);
// Loading state const handleToggleFavorite = useCallback(() => {
if (productLoading) { setIsFavorite(!isFavorite);
return ( }, [isFavorite]);
<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">
<Skeleton className="aspect-square w-full rounded-2xl" />
<div className="mt-4 flex gap-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="w-16 h-16 rounded" />
))}
</div>
</div>
<div className="flex-1 space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-20 w-full" />
</div>
</div>
</div>
)
}
// Error state const imageUrls = useMemo(() =>
if (error || !product) { product?.media?.map(m => m.images_800x800 || m.images_720x720 || m.thumbnail) || [],
return ( [product]
<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>
</div>
)
}
// Extract image URLs from media array const loadingSkeleton = useMemo(() => (
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="container mx-auto px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8"> <div className="flex flex-col lg:flex-row gap-8">
{/* Product Images */}
<div className="flex-1 max-w-2xl"> <div className="flex-1 max-w-2xl">
<div className="relative"> <Skeleton className="aspect-square w-full rounded-2xl" />
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-gray-50"> <div className="mt-4 flex gap-2">
{imageUrls.length > 0 ? ( {[1, 2, 3].map((i) => (
<Image <Skeleton key={i} className="w-16 h-16 rounded" />
src={imageUrls[selectedImage]} ))}
alt={product.name}
fill
className="object-contain"
priority
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
Surat ýok
</div>
)}
</div>
{/* Thumbnail Images */}
{imageUrls.length > 1 && (
<div className="mt-4 flex gap-2 overflow-x-auto pb-2">
{imageUrls.map((image, index) => (
<button
key={index}
onClick={() => setSelectedImage(index)}
className={`relative w-16 h-16 flex-shrink-0 rounded overflow-hidden border-2 transition-all ${
selectedImage === index
? "border-primary ring-2 ring-primary/20"
: "border-gray-200 hover:border-gray-300"
}`}
>
<Image
src={image}
alt={`${product.name} ${index + 1}`}
fill
className="object-cover"
/>
</button>
))}
</div>
)}
</div> </div>
</div> </div>
{/* Product Info */}
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
<div> <Skeleton className="h-10 w-64" />
<h1 className="text-3xl font-bold mb-2">{product.name}</h1> <Skeleton className="h-20 w-full" />
{product.categories && product.categories.length > 0 && (
<div className="flex gap-2 flex-wrap mt-2">
{product.categories.map((cat, idx) => (
<span
key={idx}
className="text-sm px-3 py-1 bg-gray-100 rounded-full text-gray-600"
>
{cat.name}
</span>
))}
</div>
)}
</div>
{/* Product Info Table */}
<Card className="p-4 rounded-xl border-gray-200">
<h3 className="text-xl font-semibold mb-4">{t.aboutProduct}</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="font-medium">{product.brand.name}</span>
</div>
<Separator />
</>
)}
{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>
</div>
<Separator />
</>
)}
{product.barcode && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t.barcode}</span>
<span className="font-mono text-sm">{product.barcode}</span>
</div>
<Separator />
</>
)}
{product.colour && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t.color}</span>
<span className="font-medium">{product.colour}</span>
</div>
<Separator />
</>
)}
{product.properties && product.properties.length > 0 && (
<>
{product.properties.map((prop, idx) => (
prop.value && (
<div key={idx}>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{prop.name}</span>
<span className="font-medium">{prop.value}</span>
</div>
{idx < product.properties.length - 1 && <Separator />}
</div>
)
))}
</>
)}
</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>
<div
className="text-gray-700 leading-relaxed prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
</Card>
)}
</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>
<div className="flex flex-col items-end">
<span className="text-3xl font-bold text-primary">
{product.price_amount} TMT
</span>
{product.old_price_amount && parseFloat(product.old_price_amount) > 0 && (
<span className="text-lg text-gray-400 line-through">
{product.old_price_amount} TMT
</span>
)}
</div>
</div>
<div className="space-y-3">
{isInCart ? (
<>
<Link href="/cart">
<Button
size="lg"
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}
</Button>
</Link>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => handleQuantityChange(quantity - 1)}
disabled={quantity === 1 || isLoading}
className="rounded-xl h-12 w-12"
>
<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>
<Button
variant="outline"
size="icon"
onClick={() => handleQuantityChange(quantity + 1)}
disabled={isLoading || quantity >= product.stock}
className="rounded-xl h-12 w-12"
>
<Plus className="h-5 w-5" />
</Button>
</div>
</>
) : (
<Button
size="lg"
onClick={handleAddToCart}
disabled={isLoading || product.stock === 0}
className="w-full rounded-xl text-lg font-bold"
>
<ShoppingCart className="mr-2 h-5 w-5" />
{isLoading ? "Goşulýar..." : product.stock === 0 ? "Haryt ýok" : t.addToCart}
</Button>
)}
<Button
variant="outline"
size="lg"
onClick={handleToggleFavorite}
className={`w-full rounded-xl transition-all ${
isFavorite
? "bg-red-50 border-red-300 hover:bg-red-100"
: "hover:bg-gray-50"
}`}
>
<Heart
className={`h-6 w-6 transition-all ${
isFavorite ? "fill-red-500 text-red-500" : "text-gray-600"
}`}
/>
</Button>
</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">
<Avatar className="w-14 h-14 bg-primary/10">
<AvatarFallback className="bg-transparent">
<Store className="h-6 w-6 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm text-gray-500">{t.store}</p>
<h4 className="text-lg font-bold">{product.channel[0].name}</h4>
</div>
</div>
<Button
variant="outline"
size="lg"
className="w-full rounded-xl"
>
{t.writeToStore}
</Button>
</Card>
)}
</div> </div>
</div> </div>
</div> </div>
) ), []);
}
export default ProductPageContent if (productLoading) {
return loadingSkeleton;
}
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8 text-center">
<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>
);
}
return (
<>
<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">
<div className="relative">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-gray-50">
{imageUrls.length > 0 ? (
<Image
src={imageUrls[selectedImage]}
alt={product.name}
fill
className="object-contain"
priority
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
{t("no_image")}
</div>
)}
</div>
{imageUrls.length > 1 && (
<div className="mt-4 flex gap-2 overflow-x-auto pb-2">
{imageUrls.map((image, index) => (
<button
key={index}
onClick={() => setSelectedImage(index)}
className={`relative w-16 h-16 flex-shrink-0 rounded overflow-hidden border-2 transition-all ${
selectedImage === index
? "border-primary ring-2 ring-primary/20"
: "border-gray-200 hover:border-gray-300"
}`}
>
<Image
src={image}
alt={`${product.name} ${index + 1}`}
fill
className="object-cover"
/>
</button>
))}
</div>
)}
</div>
</div>
<div className="flex-1 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{product.name}</h1>
{product.categories && product.categories.length > 0 && (
<div className="flex gap-2 flex-wrap mt-2">
{product.categories.map((cat, idx) => (
<span
key={idx}
className="text-sm px-3 py-1 bg-gray-100 rounded-full text-gray-600"
>
{cat.name}
</span>
))}
</div>
)}
</div>
<Card className="p-4 rounded-xl border-gray-200">
<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="font-medium">{product.brand.name}</span>
</div>
<Separator />
</>
)}
{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' : 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 />
</>
)}
{product.barcode && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t("barcode")}</span>
<span className="font-mono text-sm">{product.barcode}</span>
</div>
<Separator />
</>
)}
{product.colour && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t("color")}</span>
<span className="font-medium">{product.colour}</span>
</div>
<Separator />
</>
)}
{product.properties && product.properties.length > 0 && (
<>
{product.properties.map((prop, idx) => (
prop.value && (
<div key={idx}>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{prop.name}</span>
<span className="font-medium">{prop.value}</span>
</div>
{idx < product.properties.length - 1 && <Separator />}
</div>
)
))}
</>
)}
</div>
</Card>
{product.description && (
<Card className="p-4 rounded-xl border-gray-200">
<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 }}
/>
</Card>
)}
</div>
<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>
<div className="flex flex-col items-end">
<span className="text-3xl font-bold text-primary">
{product.price_amount} TMT
</span>
{product.old_price_amount && parseFloat(product.old_price_amount) > 0 && (
<span className="text-lg text-gray-400 line-through">
{product.old_price_amount} TMT
</span>
)}
</div>
</div>
<div className="space-y-3">
{isInCart ? (
<>
<Link href="/cart">
<Button
size="lg"
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("go_to_cart")}
</Button>
</Link>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
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 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={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>
</div>
</>
) : (
<Button
size="lg"
onClick={handleAddToCart}
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" />
{product.stock === 0 ? t("out_of_stock") : t("add_to_cart")}
</>
)}
</Button>
)}
<Button
variant="outline"
size="lg"
onClick={handleToggleFavorite}
className={`w-full rounded-xl transition-all ${
isFavorite
? "bg-red-50 border-red-300 hover:bg-red-100"
: "hover:bg-gray-50"
}`}
>
<Heart
className={`h-6 w-6 transition-all ${
isFavorite ? "fill-red-500 text-red-500" : "text-gray-600"
}`}
/>
</Button>
</div>
</Card>
{product.channel && product.channel.length > 0 && (
<Card className="p-6 rounded-xl">
<div className="flex items-center gap-4 mb-4">
<Avatar className="w-14 h-14 bg-primary/10">
<AvatarFallback className="bg-transparent">
<Store className="h-6 w-6 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm text-gray-500">{t("store")}</p>
<h4 className="text-lg font-bold">{product.channel[0].name}</h4>
</div>
</div>
<Button
variant="outline"
size="lg"
className="w-full rounded-xl"
>
{t("write_to_store")}
</Button>
</Card>
)}
</div>
</div>
</div>
<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"; "use client";
import { useCallback, useMemo } from "react";
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { Skeleton } from "@/components/ui/skeleton";
import { useUserProfile } from "@/lib/hooks"; import { useUserProfile } from "@/lib/hooks";
import { clearAuthToken } from "@/lib/api"; import { clearAuthToken } from "@/lib/api";
import { useTranslations } from "next-intl";
interface ProfilePageProps { interface ProfilePageProps {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
@@ -15,48 +17,37 @@ interface ProfilePageProps {
export default function ClientProfilePage(props: ProfilePageProps) { export default function ClientProfilePage(props: ProfilePageProps) {
const { data: user, isLoading, error } = useUserProfile(); const { data: user, isLoading, error } = useUserProfile();
const t = useTranslations();
const translations = { const handleLogout = useCallback(() => {
profile: "Профиль",
personalInfo: "Личная информация",
profileDescription: "Ваши данные профиля",
firstName: "Имя",
lastName: "Фамилия",
phone: "Номер телефона",
address: "Адрес",
logout: "Выйти",
loading: "Загрузка...",
errorLoading: "Не удалось загрузить профиль",
tryAgain: "Попробовать снова",
};
const handleLogout = () => {
clearAuthToken(); clearAuthToken();
window.location.href = "/"; window.location.href = "/";
}; }, []);
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" />
<Card className="shadow-lg mb-4">
<CardHeader>
<Skeleton className="h-6 w-32 mb-2" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
</div>
</div>
), []);
if (isLoading) { if (isLoading) {
return ( return loadingSkeleton;
<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" />
<Card className="shadow-lg mb-4">
<CardHeader>
<Skeleton className="h-6 w-32 mb-2" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
))}
</CardContent>
</Card>
</div>
</div>
);
} }
if (error) { 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"> <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardContent className="pt-6 text-center"> <CardContent className="pt-6 text-center">
<p className="text-red-600 mb-4">{translations.errorLoading}</p> <p className="text-red-600 mb-4">{t("error_loading_profile")}</p>
<Button onClick={() => window.location.reload()}>{translations.tryAgain}</Button> <Button onClick={() => window.location.reload()}>{t("try_again")}</Button>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -75,33 +66,33 @@ export default function ClientProfilePage(props: ProfilePageProps) {
return ( return (
<div className="min-h-screen bg-gray-50 p-4 pt-20"> <div className="min-h-screen bg-gray-50 p-4 pt-20">
<div className="container mx-auto max-w-2xl"> <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"> <Card className="shadow-lg mb-4">
<CardHeader> <CardHeader>
<CardTitle>{translations.personalInfo}</CardTitle> <CardTitle>{t("personal_info")}</CardTitle>
<CardDescription>{translations.profileDescription}</CardDescription> <CardDescription>{t("profile_description")}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{user && ( {user && (
<> <>
<div className="space-y-2"> <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" /> <Input id="firstName" value={user.first_name || ""} disabled className="bg-gray-50" />
</div> </div>
<div className="space-y-2"> <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" /> <Input id="lastName" value={user.last_name || ""} disabled className="bg-gray-50" />
</div> </div>
<div className="space-y-2"> <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" /> <Input id="phone" value={user.phone_number || ""} disabled className="bg-gray-50" />
</div> </div>
<div className="space-y-2"> <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" /> <Input id="address" value={user.address || ""} disabled className="bg-gray-50" />
</div> </div>
</> </>
@@ -116,7 +107,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
className="w-full max-w-md flex items-center justify-center gap-2" className="w-full max-w-md flex items-center justify-center gap-2"
> >
<LogOut className="h-5 w-5" /> <LogOut className="h-5 w-5" />
{translations.logout} {t("common.logout")}
</Button> </Button>
</div> </div>
</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 { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import { userStore } from "../userStore"; import { userStore } from "../userStore";
import type { ProfileResponse, UpdateProfileRequest, UpdateProfileResponse } from "../types"; import type { ProfileResponse, UpdateProfileRequest, UpdateProfileResponse } from "@/lib/types/api";
export const useUserProfile = () => { export const useUserProfile = () => {
return useQuery<ProfileResponse["data"]>({ 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": "Код", "code": "Код",
"send": "Отправить", "send": "Отправить",
"enterPhone": "Введите свой номер телефона", "enterPhone": "Введите свой номер телефона",
"weWillSendCode": "Мы вышлем вам код" "weWillSendCode": "Мы вышлем вам код",
"loading": "Загрузка..."
}, },
"category": "Категория", "category": "Категория",
"checkout": "Оформить заказ", "checkout": "Оформить заказ",
@@ -85,5 +86,68 @@
"seller_application_form": "Форма подачи заявления на открытие магазина", "seller_application_form": "Форма подачи заявления на открытие магазина",
"phone": "Телефон", "phone": "Телефон",
"unit_price": "Цена за 1 шт.:", "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", "code": "Kod",
"send": "Ugrat", "send": "Ugrat",
"enterPhone": "Telefon belgisini giriziň", "enterPhone": "Telefon belgisini giriziň",
"weWillSendCode": "Biz size kod ugradarys" "weWillSendCode": "Biz size kod ugradarys",
"loading": "Ýüklenýär..."
}, },
"category": "Bölümler", "category": "Bölümler",
"checkout": "Sargyt et", "checkout": "Sargyt et",
@@ -85,5 +86,68 @@
"seller_application_form": "Dükan açmak üçin arza görnüşi", "seller_application_form": "Dükan açmak üçin arza görnüşi",
"phone": "Telefon", "phone": "Telefon",
"unit_price": "1 san bahasy:", "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/cart/hooks/useCart"
export * from "../../features/favorites/hooks/useFavorites" export * from "../../features/favorites/hooks/useFavorites"
export * from "../../features/orders/hooks/useOrders" export * from "../../features/orders/hooks/useOrders"
export * from "./useSearch" export * from "../../features/search/hooks/useSearch"
export * from "../../features/profile/hooks/useUserProfile" export * from "../../features/profile/hooks/useUserProfile"
export * from "./useOpenStore" 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; images_1200x1200: string;
} }
export type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP";
export interface PaymentType {
id: number;
name: string;
code?: string;
}
export interface ProductProperty { export interface ProductProperty {
attribute_id: number; attribute_id: number;
name: string; name: string;
@@ -22,6 +30,22 @@ export interface ProductReviews {
rating: string; 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 { export interface Product {
id: number; id: number;
parent_id: number | null; parent_id: number | null;
@@ -47,22 +71,13 @@ export interface Product {
size: string | null; size: string | null;
available_colors?: string[]; available_colors?: string[];
available_sizes?: string[]; available_sizes?: string[];
brand: { brand: ProductBrand;
id: number | null; channel?: ProductChannel[];
name: string | null;
};
channel?: Array<{
id: number;
name: string;
}>;
properties?: ProductProperty[]; properties?: ProductProperty[];
variations?: any[]; variations?: any[];
reviews: ProductReviews; reviews: ProductReviews;
reviews_resources?: any[]; reviews_resources?: any[];
categories?: Array<{ categories?: ProductCategory[];
id: number;
name: string;
}>;
} }
// Category Types // Category Types
@@ -71,8 +86,10 @@ export interface Category {
name: string; name: string;
slug: string; slug: string;
image: string; image: string;
parent_id?: number; parent_id?: number | null;
children?: Category[]; children?: Category[];
media:ProductMedia[];
} }
// Collection Types // Collection Types
@@ -86,19 +103,43 @@ export interface Collection {
} }
// Cart Types // 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 { export interface CartItem {
id: number; id: number;
product_id: number; product_id: number;
product?: Product; product: CartProduct;
seller: { product_quantity: number;
seller?: {
id: number; id: number;
name: string; name: string;
}; };
quantity: number; quantity: number;
price: number; price: number;
total: number; total: number;
price_formatted?: string; price_formatted: string;
sub_total_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 { export interface Cart {
@@ -111,33 +152,84 @@ export interface Cart {
// Favorites Types // Favorites Types
export interface Favorite { export interface Favorite {
id: number; id?: number;
product_id: number; product_id: number;
product?: Product; product: Product;
added_at?: string; added_at?: string;
created_at?: string;
} }
// Order Types // Order Types
export interface OrderItem { export interface OrderProduct {
id: number; id: number;
product_id: number; name: string;
product?: Product; thumbnail: string;
images_400x400: string;
images_800x800: string;
images_1200x1200: string;
}
export interface OrderItem {
product: OrderProduct;
order: {
id: number;
};
quantity: number; quantity: number;
price: number; unit_price_amount: string;
total: number;
} }
export interface Order { export interface Order {
id: number; id: number;
number?: string; status: string;
status: "pending" | "processing" | "shipped" | "delivered" | "cancelled"; shipping_method: string;
items: OrderItem[]; notes: string | null;
total: number; customer_name: string;
total_formatted?: string; customer_phone: string;
created_at: string; customer_address: string;
updated_at?: string; delivery_time: string;
estimated_delivery?: string; delivery_at: string;
tracking_number?: 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 // Pagination Types
@@ -152,6 +244,7 @@ export interface Pagination {
last_page?: number; last_page?: number;
per_page?: number; per_page?: number;
total?: number; total?: number;
hasMorePages?: boolean;
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@@ -181,15 +274,31 @@ export interface SearchResponse {
}; };
} }
// Profile Types // User Profile Types
export interface UserProfile { export interface UserProfile {
id: number; first_name: string;
email: string; last_name: string;
phone?: string; phone_number: string;
address: string;
email?: string;
}
export interface ProfileResponse {
message: string;
data: UserProfile;
}
export interface UpdateProfileRequest {
first_name?: string; first_name?: string;
last_name?: string; last_name?: string;
avatar?: string; phone_number?: string;
created_at: string; address?: string;
email?: string;
}
export interface UpdateProfileResponse {
message: string;
data: UserProfile;
} }
// Auth Types // Auth Types
@@ -198,6 +307,26 @@ export interface AuthResponse {
user: UserProfile; 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 // Banner Types
export interface Banner { export interface Banner {
id: number; id: number;
@@ -210,19 +339,22 @@ export interface Banner {
place?: string; place?: string;
} }
// Generic API Error Response // Region and Province Types
export interface ApiError {
message: string;
errors?: Record<string, string[]>;
}
// Region, Address, PaymentType, and ShippingMethod Types
export interface Region { export interface Region {
id: number; id: number;
code: string; code: string;
name: string; name: string;
region: string;
} }
export interface Province {
id: number;
name: string;
region: string;
code?: string;
}
// Address Types
export interface Address { export interface Address {
id: number; id: number;
title: string; title: string;
@@ -232,27 +364,111 @@ export interface Address {
is_default?: boolean; is_default?: boolean;
} }
// Payment Type Options
export interface PaymentTypeOption { export interface PaymentTypeOption {
id: number; id: number;
name: string; name: string;
code: string; code: string;
} }
// Shipping Method Types
export interface ShippingMethod { export interface ShippingMethod {
id: number; id: number;
name: string; name: string;
code: string; code: string;
} }
// Order creation payload type // Generic API Error Response
export interface CreateOrderPayload { export interface ApiError {
customer_name?: string; message: string;
customer_phone?: string; errors?: Record<string, string[]>;
customer_address: string; error?: string;
shipping_method: string;
payment_type_id: number;
delivery_time?: string;
delivery_at?: string;
region: string;
note?: 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;
}