fixed some ui, refactored code
This commit is contained in:
@@ -146,7 +146,7 @@ export default function CartPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="container mx-auto px-2 md:px-4 lg:px-6 mb-18">
|
||||
<h1 className="text-3xl font-bold mb-6 pt-3">{t("cart")}</h1>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
|
||||
@@ -31,6 +31,6 @@ export default async function CategoryPage(props: Props) {
|
||||
const params = await props.params
|
||||
const { slug } = params
|
||||
|
||||
const CategoryPageClient = (await import("../../../../features/category/components/CategoryClient")).default
|
||||
const CategoryPageClient = (await import("../../../../features/category/components/CategoryPageClient")).default
|
||||
return <CategoryPageClient params={params} />
|
||||
}
|
||||
|
||||
@@ -35,11 +35,10 @@ export default function FavoritesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6
|
||||
md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]
|
||||
<div className="container mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]
|
||||
">
|
||||
<h1 className="bg-white text-3xl p-4 font-bold mb-0 pb-6">{t("favorite_products")}</h1>
|
||||
<div className="bg-white grid grid-cols-2 sm:grid-cols-3 rounded-lg md:grid-cols-4 lg:grid-cols-5 gap-3 p-4">
|
||||
<div className="bg-white grid grid-cols-2 sm:grid-cols-3 rounded-b-lg md:grid-cols-4 lg:grid-cols-5 gap-3 p-4">
|
||||
{favorites.map((favorite: Favorite) => {
|
||||
const product = favorite.product;
|
||||
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import type { Metadata } from "next"
|
||||
import OrdersPageClient from "../../../features/orders/components/OrderPage"
|
||||
import type { Metadata, ResolvingMetadata } from "next";
|
||||
import OrdersPageClient from "../../../features/orders/components/OrderPage";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "My Orders | E-Commerce",
|
||||
description: "View your order history",
|
||||
robots: "noindex, nofollow", // Private page
|
||||
}
|
||||
const metadataContent = {
|
||||
tm: {
|
||||
title: "Meniň Sargytlarym | Post shop",
|
||||
description: "Sargytlaryňyzy görüň",
|
||||
},
|
||||
ru: {
|
||||
title: "Мои Заказы | Пост-магазин",
|
||||
description: "Просмотр истории заказов",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
locale: string
|
||||
}>
|
||||
params: {
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata(
|
||||
{ params }: PageProps,
|
||||
parent: ResolvingMetadata
|
||||
): Promise<Metadata> {
|
||||
const locale = params.locale as keyof typeof metadataContent;
|
||||
|
||||
const content = metadataContent[locale] || metadataContent.ru;
|
||||
|
||||
return {
|
||||
title: content.title,
|
||||
description: content.description,
|
||||
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
nocache: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OrdersPage({ params }: PageProps) {
|
||||
const resolvedParams = await params
|
||||
|
||||
return <OrdersPageClient locale={resolvedParams.locale} />
|
||||
return <OrdersPageClient locale={params.locale} />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import type { Metadata } from "next";
|
||||
import HomePage from "@/features/home/components/HomePage";
|
||||
|
||||
export const revalidate = 300;
|
||||
const META = {
|
||||
ru: {
|
||||
title: "Интернет магазин - Лучшие товары по низким ценам",
|
||||
description: "Качественные товары с быстрой доставкой по всей стране",
|
||||
},
|
||||
tm: {
|
||||
title: "Post shop - Iň gowy harytlar, amatly bahada",
|
||||
description:
|
||||
"Ýokary hilli harytlar. Elektronika, eşik, arassaçylyk, sport, kosmetika",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -9,19 +19,7 @@ export async function generateMetadata({
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
|
||||
const meta = {
|
||||
ru: {
|
||||
title: "Интернет магазин - Лучшие товары по низким ценам",
|
||||
description: "Качественные товары с быстрой доставкой по всей стране",
|
||||
},
|
||||
tm: {
|
||||
title: "Satym dükanı - Iň gowy harytlar aşak bahada",
|
||||
description: "Suw harytly towarnama. Elektrika, eşik, ev we bag",
|
||||
},
|
||||
};
|
||||
|
||||
const { title, description } = meta[locale as keyof typeof meta] || meta.ru;
|
||||
const { title, description } = META[locale as keyof typeof META] || META.ru;
|
||||
|
||||
return {
|
||||
title,
|
||||
|
||||
@@ -3,9 +3,8 @@ import { notFound } from "next/navigation";
|
||||
import ProductPageContent from "../../../../features/products/components/ProductPageContent";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
params: { locale: string; slug: string };
|
||||
};
|
||||
|
||||
export const revalidate = 3600; // ISR: Revalidate every hour
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
|
||||
@@ -2,7 +2,6 @@ export const FavoriteIcon = () => (
|
||||
<svg
|
||||
fill="gray"
|
||||
aria-hidden="true"
|
||||
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
|
||||
data-testid="FavoriteBorderIcon"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
@@ -13,7 +12,7 @@ export const OrderIcon = () => (
|
||||
<svg
|
||||
fill="gray"
|
||||
aria-hidden="true"
|
||||
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
|
||||
|
||||
data-testid="LocalShippingIcon"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
@@ -24,7 +23,7 @@ export const CartIcon = () => (
|
||||
<svg
|
||||
fill="gray"
|
||||
aria-hidden="true"
|
||||
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
|
||||
|
||||
data-testid="ShoppingBasketIcon"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
@@ -35,7 +34,7 @@ export const CategoryIcon = () => (
|
||||
<svg
|
||||
fill="white"
|
||||
aria-hidden="true"
|
||||
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-nfmerv"
|
||||
|
||||
data-testid="WidgetsIcon"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
@@ -46,7 +45,7 @@ export const SearchIcon = () => (
|
||||
<svg
|
||||
fill="white"
|
||||
aria-hidden="true"
|
||||
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
|
||||
|
||||
data-testid="SearchIcon"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
@@ -57,7 +56,7 @@ export const ProfileIcon = () => (
|
||||
<svg
|
||||
fill="gray"
|
||||
aria-hidden="true"
|
||||
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
|
||||
|
||||
data-testid="FaceIcon"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
|
||||
@@ -11,9 +11,11 @@ import SearchBar from "./ui/SearchBar";
|
||||
import AuthDialog from "./ui/AuthDialog";
|
||||
import ActionButtons from "./ui/ActionButtons";
|
||||
import LanguageSelector from "./ui/LanguageSelector";
|
||||
import MobileBottomNav from "./MobileBar";
|
||||
import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CategoryIcon } from "../icons";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface HeaderProps {
|
||||
locale?: string;
|
||||
@@ -25,6 +27,7 @@ export default function Header({ locale = "ru" }: HeaderProps) {
|
||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||
const [isLoginOpen, setIsLoginOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const { isAuthenticated, isLoading } = useAuthStatus();
|
||||
const { mutate: logout, isPending: isLoggingOut } = useLogout();
|
||||
@@ -53,6 +56,10 @@ export default function Header({ locale = "ru" }: HeaderProps) {
|
||||
setIsCategoryOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleProfileClick = useCallback(() => {
|
||||
router.push(`/${locale}/me`);
|
||||
}, [router, locale]);
|
||||
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
@@ -103,8 +110,6 @@ export default function Header({ locale = "ru" }: HeaderProps) {
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<ActionButtons
|
||||
isAuthenticated={isAuthenticated}
|
||||
onAuthClick={handleAuthClick}
|
||||
@@ -124,6 +129,21 @@ export default function Header({ locale = "ru" }: HeaderProps) {
|
||||
/>
|
||||
|
||||
<AuthDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} />
|
||||
|
||||
<MobileBottomNav
|
||||
locale={locale}
|
||||
isAuthenticated={isAuthenticated}
|
||||
translations={{
|
||||
catalog: t("common.catalog"),
|
||||
favorites: t("common.favorites"),
|
||||
orders: t("common.orders"),
|
||||
cart: t("common.cart"),
|
||||
login: t("common.login"),
|
||||
profile: t("profile"),
|
||||
}}
|
||||
onLoginClick={() => setIsLoginOpen(true)}
|
||||
onProfileClick={handleProfileClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { Menu, Heart, Truck, ShoppingCart, User } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useCategories, useCart, useFavorites, useOrders } from "@/lib/hooks"
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu, Heart, Truck, ShoppingCart, User } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useCategories, useCart, useFavorites, useOrders } from "@/lib/hooks";
|
||||
import { useRouter } from "next/navigation";
|
||||
interface MobileBottomNavProps {
|
||||
locale?: string
|
||||
isAuthenticated?: boolean
|
||||
locale?: string;
|
||||
isAuthenticated?: boolean;
|
||||
translations?: {
|
||||
catalog: string
|
||||
favorites: string
|
||||
orders: string
|
||||
cart: string
|
||||
login: string
|
||||
profile: string
|
||||
}
|
||||
onLoginClick?: () => void
|
||||
catalog: string;
|
||||
favorites: string;
|
||||
orders: string;
|
||||
cart: string;
|
||||
login: string;
|
||||
profile: string;
|
||||
};
|
||||
onLoginClick?: () => void;
|
||||
onProfileClick?: () => void;
|
||||
}
|
||||
|
||||
export default function MobileBottomNav({
|
||||
@@ -28,15 +34,16 @@ export default function MobileBottomNav({
|
||||
isAuthenticated = false,
|
||||
translations,
|
||||
onLoginClick,
|
||||
onProfileClick, // EKLENEN
|
||||
}: MobileBottomNavProps) {
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
const [isCategoryOpen, setIsCategoryOpen] = useState(false)
|
||||
|
||||
const { data: categories = [] } = useCategories()
|
||||
const { data: cartData } = useCart()
|
||||
const { data: favoritesData } = useFavorites()
|
||||
const { data: ordersData } = useOrders()
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [isCategoryOpen, setIsCategoryOpen] = useState(false);
|
||||
|
||||
const { data: categories = [] } = useCategories();
|
||||
const { data: cartData } = useCart();
|
||||
const { data: favoritesData } = useFavorites();
|
||||
const { data: ordersData } = useOrders();
|
||||
const router = useRouter();
|
||||
const t = translations || {
|
||||
catalog: "Каталог",
|
||||
favorites: "Избранное",
|
||||
@@ -44,21 +51,26 @@ export default function MobileBottomNav({
|
||||
cart: "Корзина",
|
||||
login: "Войти",
|
||||
profile: "Профиль",
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const handleAuthClick = () => {
|
||||
if (isAuthenticated) {
|
||||
window.location.href = `/${locale}/me`
|
||||
if (onProfileClick) {
|
||||
onProfileClick();
|
||||
} else {
|
||||
router.push(`/${locale}/me`);
|
||||
console.log("hello");
|
||||
}
|
||||
} else if (onLoginClick) {
|
||||
onLoginClick()
|
||||
onLoginClick();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isClient) return null
|
||||
if (!isClient) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,7 +90,11 @@ export default function MobileBottomNav({
|
||||
|
||||
{/* Favorites Button */}
|
||||
<Link href="/favorites">
|
||||
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative flex-col gap-0.5 h-auto px-2 py-2"
|
||||
>
|
||||
<div className="relative">
|
||||
<Heart className="h-5 w-5 text-gray-600" />
|
||||
<Badge
|
||||
@@ -94,7 +110,11 @@ export default function MobileBottomNav({
|
||||
|
||||
{/* Orders Button */}
|
||||
<Link href="/orders">
|
||||
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative flex-col gap-0.5 h-auto px-2 py-2"
|
||||
>
|
||||
<div className="relative">
|
||||
<Truck className="h-5 w-5 text-gray-600" />
|
||||
<Badge
|
||||
@@ -110,25 +130,46 @@ export default function MobileBottomNav({
|
||||
|
||||
{/* Cart Button */}
|
||||
<Link href="/cart">
|
||||
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="relative flex-col gap-0.5 h-auto px-2 py-2"
|
||||
>
|
||||
<div className="relative">
|
||||
<ShoppingCart className="h-5 w-5 text-gray-600" />
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
|
||||
>
|
||||
{cartData?.count || 0}
|
||||
{cartData?.data?.length || 0}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-gray-700">{t.cart}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{/* Profile/Login Button */}
|
||||
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2" onClick={handleAuthClick}>
|
||||
<User className="h-5 w-5 text-gray-600" />
|
||||
<span className="text-xs text-gray-700">{isAuthenticated ? t.profile : t.login}</span>
|
||||
</Button>
|
||||
{isAuthenticated ? (
|
||||
<Link href={`/${locale}/me`}>
|
||||
<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>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-col gap-0.5 h-auto px-2 py-2"
|
||||
onClick={onLoginClick}
|
||||
>
|
||||
<User className="h-5 w-5 text-gray-600" />
|
||||
<span className="text-xs text-gray-700">{t.login}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,7 +188,6 @@ export default function MobileBottomNav({
|
||||
onClick={() => setIsCategoryOpen(false)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors font-semibold"
|
||||
>
|
||||
{category.icon_class && <i className={`${category.icon_class} text-xl`}></i>}
|
||||
<span>{category.name}</span>
|
||||
</Link>
|
||||
|
||||
@@ -173,5 +213,5 @@ export default function MobileBottomNav({
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import type React from "react";
|
||||
import Link from "next/link";
|
||||
@@ -47,7 +47,7 @@ export default function ActionButtons({
|
||||
}: ActionButtonsProps) {
|
||||
const t = useTranslations();
|
||||
const { mutate: logout, isPending: isLoggingOut } = useLogout();
|
||||
|
||||
const router = useRouter();
|
||||
const { data: cartData, isLoading: cartLoading } = useCart();
|
||||
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites();
|
||||
const { data: ordersData, isLoading: ordersLoading } = useOrders();
|
||||
@@ -101,8 +101,7 @@ export default function ActionButtons({
|
||||
href: "/cart",
|
||||
badgeCount: cartCount,
|
||||
isLoading: cartLoading,
|
||||
}
|
||||
|
||||
},
|
||||
],
|
||||
[
|
||||
ordersCount,
|
||||
@@ -133,9 +132,7 @@ export default function ActionButtons({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => (window.location.href = `/${locale}/me`)}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => router.push(`/${locale}/me`)}>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{t("profile")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -26,13 +27,18 @@ const LANGUAGES: Language[] = [
|
||||
export default function LanguageSelector() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname(); // Şu anki path'i al
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleLanguageChange = async (newLocale: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).i18n = { language: newLocale };
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
const handleLanguageChange = (newLocale: string) => {
|
||||
// Mevcut path'i yeni locale ile değiştir
|
||||
// Örnek: /tm/cart -> /ru/cart
|
||||
const currentPath = pathname.replace(`/${locale}`, "");
|
||||
router.push(`/${newLocale}${currentPath}`);
|
||||
router.replace(`/${newLocale}${currentPath}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SlidersHorizontal, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import ProductCard from "@/features/home/components/ProductCard";
|
||||
import {
|
||||
useCategories,
|
||||
useCategoryFilters,
|
||||
useFilteredCategoryProducts,
|
||||
} from "@/features/category/hooks/useCategories";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Category, Product } from "@/lib/types/api";
|
||||
|
||||
interface CategoryPageClientProps {
|
||||
params: { locale: string; slug: string };
|
||||
}
|
||||
|
||||
export default function CategoryPageClient({
|
||||
params,
|
||||
}: CategoryPageClientProps) {
|
||||
const { slug, locale } = params;
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const { data: categoriesData, isLoading: categoriesLoading } =
|
||||
useCategories();
|
||||
|
||||
const selectedCategory = useMemo(() => {
|
||||
if (!categoriesData || !slug) return null;
|
||||
|
||||
const findBySlug = (categories: Category[]): Category | null => {
|
||||
for (const category of categories) {
|
||||
if (category.slug === slug) return category;
|
||||
if (category.children) {
|
||||
const found = findBySlug(category.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return findBySlug(categoriesData);
|
||||
}, [categoriesData, slug]);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||
const [priceSort, setPriceSort] = useState<
|
||||
"none" | "lowToHigh" | "highToLow"
|
||||
>("none");
|
||||
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
|
||||
const [selectedBrands, setSelectedBrands] = useState<Set<number>>(new Set());
|
||||
const [selectedFilterCategories, setSelectedFilterCategories] = useState<
|
||||
Set<number>
|
||||
>(new Set());
|
||||
|
||||
// Fetch filters
|
||||
const { data: filtersData, isLoading: filtersLoading } = useCategoryFilters(
|
||||
selectedCategory?.id,
|
||||
{ enabled: !!selectedCategory }
|
||||
);
|
||||
|
||||
// Build filter params
|
||||
const filterParams = useMemo(() => {
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
limit: 6,
|
||||
};
|
||||
|
||||
if (selectedBrands.size > 0) {
|
||||
params.brands = Array.from(selectedBrands);
|
||||
}
|
||||
|
||||
if (selectedFilterCategories.size > 0) {
|
||||
params.categories = Array.from(selectedFilterCategories);
|
||||
}
|
||||
|
||||
if (priceRange[0] > 0) {
|
||||
params.min_price = priceRange[0];
|
||||
}
|
||||
|
||||
if (priceRange[1] < 10000) {
|
||||
params.max_price = priceRange[1];
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [currentPage, selectedBrands, selectedFilterCategories, priceRange]);
|
||||
|
||||
// Fetch filtered products
|
||||
const {
|
||||
data: productsData,
|
||||
isLoading: productsLoading,
|
||||
isFetching,
|
||||
} = useFilteredCategoryProducts(
|
||||
selectedCategory?.id?.toString() || "",
|
||||
filterParams,
|
||||
{ enabled: !!selectedCategory }
|
||||
);
|
||||
|
||||
// Reset on category change
|
||||
useEffect(() => {
|
||||
if (selectedCategory) {
|
||||
setAllProducts([]);
|
||||
setCurrentPage(1);
|
||||
setSelectedBrands(new Set());
|
||||
setSelectedFilterCategories(new Set());
|
||||
setPriceRange([0, 10000]);
|
||||
setPriceSort("none");
|
||||
}
|
||||
}, [selectedCategory?.id]);
|
||||
|
||||
// Update products list
|
||||
useEffect(() => {
|
||||
if (productsData?.data) {
|
||||
setAllProducts((prev) => {
|
||||
if (currentPage === 1) {
|
||||
return productsData.data;
|
||||
}
|
||||
const existingIds = new Set(prev.map((p) => p.id));
|
||||
const newProducts = productsData.data.filter(
|
||||
(p: Product) => !existingIds.has(p.id)
|
||||
);
|
||||
return [...prev, ...newProducts];
|
||||
});
|
||||
}
|
||||
}, [productsData, currentPage]);
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
return !!productsData?.pagination?.next_page_url;
|
||||
}, [productsData]);
|
||||
|
||||
const loadMoreData = useCallback(() => {
|
||||
if (!hasMore || isFetching) return;
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}, [hasMore, isFetching]);
|
||||
|
||||
const sortedProducts = useMemo(() => {
|
||||
const products = [...allProducts];
|
||||
if (priceSort === "lowToHigh") {
|
||||
return products.sort(
|
||||
(a, b) =>
|
||||
parseFloat(a.price_amount || "0") - parseFloat(b.price_amount || "0")
|
||||
);
|
||||
}
|
||||
if (priceSort === "highToLow") {
|
||||
return products.sort(
|
||||
(a, b) =>
|
||||
parseFloat(b.price_amount || "0") - parseFloat(a.price_amount || "0")
|
||||
);
|
||||
}
|
||||
return products;
|
||||
}, [allProducts, priceSort]);
|
||||
|
||||
const handleBrandToggle = useCallback((brandId: number) => {
|
||||
setSelectedBrands((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(brandId)) {
|
||||
newSet.delete(brandId);
|
||||
} else {
|
||||
newSet.add(brandId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const handleCategoryToggle = useCallback((categoryId: number) => {
|
||||
setSelectedFilterCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(categoryId)) {
|
||||
newSet.delete(categoryId);
|
||||
} else {
|
||||
newSet.add(categoryId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const handlePriceChange = useCallback((values: number[]) => {
|
||||
setPriceRange([values[0], values[1]]);
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const handlePriceSortChange = useCallback(
|
||||
(sortType: "none" | "lowToHigh" | "highToLow") => {
|
||||
setPriceSort(sortType);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSelectedBrands(new Set());
|
||||
setSelectedFilterCategories(new Set());
|
||||
setPriceRange([0, 10000]);
|
||||
setPriceSort("none");
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const findCategoryById = useCallback(
|
||||
(categories: Category[] | undefined, id: number): Category | null => {
|
||||
if (!categories) return null;
|
||||
for (const category of categories) {
|
||||
if (category.id === id) return category;
|
||||
if (category.children) {
|
||||
const found = findCategoryById(category.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
|
||||
|
||||
const FiltersContent = useCallback(
|
||||
() => (
|
||||
<div className="space-y-6">
|
||||
{filtersData?.categories && filtersData.categories.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">{t("category")}</h3>
|
||||
<div className="space-y-2">
|
||||
{filtersData.categories.map((category) => (
|
||||
<label
|
||||
key={category.id}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFilterCategories.has(category.id)}
|
||||
onCheckedChange={() => handleCategoryToggle(category.id)}
|
||||
/>
|
||||
<span className="text-sm">{category.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtersData?.brands && filtersData.brands.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">{t("brands")}</h3>
|
||||
<div className="space-y-2">
|
||||
{filtersData.brands.map((brand) => (
|
||||
<label
|
||||
key={brand.id}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedBrands.has(brand.id)}
|
||||
onCheckedChange={() => handleBrandToggle(brand.id)}
|
||||
/>
|
||||
<span className="text-sm">{brand.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">{t("sort")}</h3>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="sort"
|
||||
checked={priceSort === "none"}
|
||||
onChange={() => handlePriceSortChange("none")}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{t("default")}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<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>
|
||||
|
||||
<PriceFilter
|
||||
title={t("price")}
|
||||
priceRange={priceRange}
|
||||
onPriceChange={handlePriceChange}
|
||||
translations={{ from: t("price_from"), to: t("price_to") }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full rounded-xl"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
[
|
||||
filtersData,
|
||||
selectedFilterCategories,
|
||||
selectedBrands,
|
||||
priceSort,
|
||||
priceRange,
|
||||
t,
|
||||
handleCategoryToggle,
|
||||
handleBrandToggle,
|
||||
handlePriceSortChange,
|
||||
handlePriceChange,
|
||||
resetFilters,
|
||||
]
|
||||
);
|
||||
|
||||
if (categoriesLoading) return <div>{t("common.loading")}</div>;
|
||||
if (!selectedCategory)
|
||||
return <div className="text-center py-8">{t("category_not_found")}</div>;
|
||||
|
||||
const totalItems =
|
||||
productsData?.pagination?.total || sortedProducts.length || 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mx-auto max-w-[1504px]
|
||||
px-2 md:px-4 lg:px-6 pb-12
|
||||
">
|
||||
<h2 className="p-4 text-3xl font-bold pb-6 rounded-lg mb-0 bg-white">{selectedCategory.name}</h2>
|
||||
|
||||
|
||||
<div className="flex gap-4 bg-white rounded-lg">
|
||||
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4 ">
|
||||
<ScrollArea className="h-[calc(100vh-200px)] ">
|
||||
<FiltersContent />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-white rounded-lg">
|
||||
{sortedProducts.length > 0 ? (
|
||||
<InfiniteScroll
|
||||
dataLength={sortedProducts.length}
|
||||
next={loadMoreData}
|
||||
hasMore={hasMore}
|
||||
scrollThreshold={0.8}
|
||||
style={{ overflow: "visible" }}
|
||||
loader={
|
||||
<div className="flex justify-center py-4">
|
||||
<div>{t("common.loading")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="bg-white rounded-lg grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{sortedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
price={
|
||||
product.price_amount
|
||||
? parseFloat(product.price_amount)
|
||||
: null
|
||||
}
|
||||
struct_price_text={`${product.price_amount} TMT`}
|
||||
images={[product.media?.[0]?.images_400x400]}
|
||||
|
||||
button={true} />
|
||||
))}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{t("no_results")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
className="sm:hidden fixed bottom-20 right-4 rounded-xl font-bold gap-2 z-10 shadow-lg"
|
||||
size="lg"
|
||||
>
|
||||
{t("filter")}
|
||||
<SlidersHorizontal className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[290px] p-0">
|
||||
<SheetHeader className="p-4 border-b">
|
||||
<SheetTitle>{t("filter")}</SheetTitle>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-4 right-4 rounded-md ring-offset-background transition-opacity hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{t("close")}</span>
|
||||
</button>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-80px)] p-4">
|
||||
<FiltersContent />
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceFilter({
|
||||
title,
|
||||
priceRange,
|
||||
onPriceChange,
|
||||
translations,
|
||||
}: {
|
||||
title: string;
|
||||
priceRange: [number, number];
|
||||
onPriceChange: (values: number[]) => void;
|
||||
translations: { from: string; to: string };
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="price-from" className="text-xs mb-1">
|
||||
{translations.from}
|
||||
</Label>
|
||||
<Input
|
||||
id="price-from"
|
||||
type="number"
|
||||
value={priceRange[0]}
|
||||
onChange={(e) =>
|
||||
onPriceChange([parseInt(e.target.value) || 0, priceRange[1]])
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="price-to" className="text-xs mb-1">
|
||||
{translations.to}
|
||||
</Label>
|
||||
<Input
|
||||
id="price-to"
|
||||
type="number"
|
||||
value={priceRange[1]}
|
||||
onChange={(e) =>
|
||||
onPriceChange([
|
||||
priceRange[0],
|
||||
parseInt(e.target.value) || 10000,
|
||||
])
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={99999}
|
||||
step={100}
|
||||
value={priceRange}
|
||||
onValueChange={onPriceChange}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
features/category/components/CategoryFilters.tsx
Normal file
234
features/category/components/CategoryFilters.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useCallback } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import type { FilterBrand, FilterCategory } from "@/lib/types/api";
|
||||
|
||||
interface FiltersData {
|
||||
categories: FilterCategory[];
|
||||
brands: FilterBrand[];
|
||||
}
|
||||
|
||||
interface CategoryFiltersProps {
|
||||
filtersData: FiltersData | undefined;
|
||||
selectedBrands: Set<number>;
|
||||
selectedFilterCategories: Set<number>;
|
||||
priceSort: "none" | "lowToHigh" | "highToLow";
|
||||
priceRange: [number, number];
|
||||
onBrandToggle: (brandId: number) => void;
|
||||
onCategoryToggle: (categoryId: number) => void;
|
||||
onPriceSortChange: (sortType: "none" | "lowToHigh" | "highToLow") => void;
|
||||
onPriceChange: (values: number[]) => void;
|
||||
onReset: () => void;
|
||||
translations: {
|
||||
category: string;
|
||||
brands: string;
|
||||
sort: string;
|
||||
default: string;
|
||||
price_low_to_high: string;
|
||||
price_high_to_low: string;
|
||||
price: string;
|
||||
price_from: string;
|
||||
price_to: string;
|
||||
reset: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CategoryFilters({
|
||||
filtersData,
|
||||
selectedBrands,
|
||||
selectedFilterCategories,
|
||||
priceSort,
|
||||
priceRange,
|
||||
onBrandToggle,
|
||||
onCategoryToggle,
|
||||
onPriceSortChange,
|
||||
onPriceChange,
|
||||
onReset,
|
||||
translations,
|
||||
}: CategoryFiltersProps) {
|
||||
return (
|
||||
<div className="space-y-6 mb-6">
|
||||
{filtersData?.categories && filtersData.categories.length > 0 && (
|
||||
<FilterSection title={translations.category}>
|
||||
{filtersData.categories.map((category) => (
|
||||
<CheckboxItem
|
||||
key={category.id}
|
||||
checked={selectedFilterCategories.has(category.id)}
|
||||
onCheckedChange={() => onCategoryToggle(category.id)}
|
||||
label={category.name}
|
||||
/>
|
||||
))}
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{filtersData?.brands && filtersData.brands.length > 0 && (
|
||||
<FilterSection title={translations.brands}>
|
||||
{filtersData.brands.map((brand) => (
|
||||
<CheckboxItem
|
||||
key={brand.id}
|
||||
checked={selectedBrands.has(brand.id)}
|
||||
onCheckedChange={() => onBrandToggle(brand.id)}
|
||||
label={brand.name}
|
||||
/>
|
||||
))}
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
<FilterSection title={translations.sort}>
|
||||
<RadioItem
|
||||
name="sort"
|
||||
checked={priceSort === "none"}
|
||||
onChange={() => onPriceSortChange("none")}
|
||||
label={translations.default}
|
||||
/>
|
||||
<RadioItem
|
||||
name="sort"
|
||||
checked={priceSort === "lowToHigh"}
|
||||
onChange={() => onPriceSortChange("lowToHigh")}
|
||||
label={translations.price_low_to_high}
|
||||
/>
|
||||
<RadioItem
|
||||
name="sort"
|
||||
checked={priceSort === "highToLow"}
|
||||
onChange={() => onPriceSortChange("highToLow")}
|
||||
label={translations.price_high_to_low}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
<PriceFilter
|
||||
title={translations.price}
|
||||
priceRange={priceRange}
|
||||
onPriceChange={onPriceChange}
|
||||
translations={{
|
||||
from: translations.price_from,
|
||||
to: translations.price_to,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button variant="outline" className="w-full rounded-xl" onClick={onReset}>
|
||||
{translations.reset}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||
<div className="space-y-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxItem({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
|
||||
<span className="text-sm">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function RadioItem({
|
||||
name,
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
name: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceFilter({
|
||||
title,
|
||||
priceRange,
|
||||
onPriceChange,
|
||||
translations,
|
||||
}: {
|
||||
title: string;
|
||||
priceRange: [number, number];
|
||||
onPriceChange: (values: number[]) => void;
|
||||
translations: { from: string; to: string };
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">{title}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="price-from" className="text-xs mb-1">
|
||||
{translations.from}
|
||||
</Label>
|
||||
<Input
|
||||
id="price-from"
|
||||
type="number"
|
||||
value={priceRange[0]}
|
||||
onChange={(e) =>
|
||||
onPriceChange([parseInt(e.target.value) || 0, priceRange[1]])
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="price-to" className="text-xs mb-1">
|
||||
{translations.to}
|
||||
</Label>
|
||||
<Input
|
||||
id="price-to"
|
||||
type="number"
|
||||
value={priceRange[1]}
|
||||
onChange={(e) =>
|
||||
onPriceChange([
|
||||
priceRange[0],
|
||||
parseInt(e.target.value) || 10000,
|
||||
])
|
||||
}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
min={0}
|
||||
max={99999}
|
||||
step={100}
|
||||
value={priceRange}
|
||||
onValueChange={onPriceChange}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
features/category/components/CategoryFiltersSheet.tsx
Normal file
55
features/category/components/CategoryFiltersSheet.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { SlidersHorizontal, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface CategoryFiltersSheetProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
filterLabel: string;
|
||||
closeLabel: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function CategoryFiltersSheet({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
filterLabel,
|
||||
closeLabel,
|
||||
children,
|
||||
}: CategoryFiltersSheetProps) {
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
className="sm:hidden fixed bottom-20 right-4 rounded-xl font-bold gap-2 z-10 shadow-lg"
|
||||
size="lg"
|
||||
>
|
||||
{filterLabel}
|
||||
<SlidersHorizontal className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[290px] p-0">
|
||||
<SheetHeader className="p-4 border-b">
|
||||
<SheetTitle>{filterLabel}</SheetTitle>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute top-4 right-4 rounded-md ring-offset-background transition-opacity hover:opacity-100"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">{closeLabel}</span>
|
||||
</button>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="h-[calc(100vh-80px)] p-4">
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
279
features/category/components/CategoryPageClient.tsx
Normal file
279
features/category/components/CategoryPageClient.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
useCategories,
|
||||
useCategoryFilters,
|
||||
useFilteredCategoryProducts,
|
||||
} from "@/features/category/hooks/useCategories";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Category, Product } from "@/lib/types/api";
|
||||
import CategoryFilters from "./CategoryFilters";
|
||||
import CategoryProductsGrid from "./CategoryProductsGrid";
|
||||
import CategoryFiltersSheet from "./CategoryFiltersSheet";
|
||||
|
||||
interface CategoryPageClientProps {
|
||||
params: { locale: string; slug: string };
|
||||
}
|
||||
|
||||
export default function CategoryPageClient({
|
||||
params,
|
||||
}: CategoryPageClientProps) {
|
||||
const { slug } = params;
|
||||
const t = useTranslations();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
|
||||
const { data: categoriesData, isLoading: categoriesLoading } =
|
||||
useCategories();
|
||||
|
||||
const selectedCategory = useMemo(() => {
|
||||
if (!categoriesData || !slug) return null;
|
||||
|
||||
const findBySlug = (categories: Category[]): Category | null => {
|
||||
for (const category of categories) {
|
||||
if (category.slug === slug) return category;
|
||||
if (category.children) {
|
||||
const found = findBySlug(category.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return findBySlug(categoriesData);
|
||||
}, [categoriesData, slug]);
|
||||
|
||||
// State management
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||
const [priceSort, setPriceSort] = useState<
|
||||
"none" | "lowToHigh" | "highToLow"
|
||||
>("none");
|
||||
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
|
||||
const [selectedBrands, setSelectedBrands] = useState<Set<number>>(new Set());
|
||||
const [selectedFilterCategories, setSelectedFilterCategories] = useState<
|
||||
Set<number>
|
||||
>(new Set());
|
||||
|
||||
// Fetch filters
|
||||
const { data: filtersData } = useCategoryFilters(selectedCategory?.id, {
|
||||
enabled: !!selectedCategory,
|
||||
});
|
||||
|
||||
// Build filter params
|
||||
const filterParams = useMemo(() => {
|
||||
const params: any = {
|
||||
page: currentPage,
|
||||
limit: 6,
|
||||
};
|
||||
|
||||
if (selectedBrands.size > 0) {
|
||||
params.brands = Array.from(selectedBrands);
|
||||
}
|
||||
|
||||
if (selectedFilterCategories.size > 0) {
|
||||
params.categories = Array.from(selectedFilterCategories);
|
||||
}
|
||||
|
||||
if (priceRange[0] > 0) {
|
||||
params.min_price = priceRange[0];
|
||||
}
|
||||
|
||||
if (priceRange[1] < 10000) {
|
||||
params.max_price = priceRange[1];
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [currentPage, selectedBrands, selectedFilterCategories, priceRange]);
|
||||
|
||||
// Fetch filtered products
|
||||
const { data: productsData, isFetching } = useFilteredCategoryProducts(
|
||||
selectedCategory?.id?.toString() || "",
|
||||
filterParams,
|
||||
{ enabled: !!selectedCategory }
|
||||
);
|
||||
|
||||
// Reset on category change
|
||||
useEffect(() => {
|
||||
if (selectedCategory) {
|
||||
setAllProducts([]);
|
||||
setCurrentPage(1);
|
||||
setSelectedBrands(new Set());
|
||||
setSelectedFilterCategories(new Set());
|
||||
setPriceRange([0, 10000]);
|
||||
setPriceSort("none");
|
||||
}
|
||||
}, [selectedCategory?.id]);
|
||||
|
||||
// Update products list
|
||||
useEffect(() => {
|
||||
if (productsData?.data) {
|
||||
setAllProducts((prev) => {
|
||||
if (currentPage === 1) {
|
||||
return productsData.data;
|
||||
}
|
||||
const existingIds = new Set(prev.map((p) => p.id));
|
||||
const newProducts = productsData.data.filter(
|
||||
(p: Product) => !existingIds.has(p.id)
|
||||
);
|
||||
return [...prev, ...newProducts];
|
||||
});
|
||||
}
|
||||
}, [productsData, currentPage]);
|
||||
|
||||
const hasMore = useMemo(() => {
|
||||
return !!productsData?.pagination?.next_page_url;
|
||||
}, [productsData]);
|
||||
|
||||
const loadMoreData = useCallback(() => {
|
||||
if (!hasMore || isFetching) return;
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
}, [hasMore, isFetching]);
|
||||
|
||||
const sortedProducts = useMemo(() => {
|
||||
const products = [...allProducts];
|
||||
if (priceSort === "lowToHigh") {
|
||||
return products.sort(
|
||||
(a, b) =>
|
||||
parseFloat(a.price_amount || "0") - parseFloat(b.price_amount || "0")
|
||||
);
|
||||
}
|
||||
if (priceSort === "highToLow") {
|
||||
return products.sort(
|
||||
(a, b) =>
|
||||
parseFloat(b.price_amount || "0") - parseFloat(a.price_amount || "0")
|
||||
);
|
||||
}
|
||||
return products;
|
||||
}, [allProducts, priceSort]);
|
||||
|
||||
// Filter handlers
|
||||
const handleBrandToggle = useCallback((brandId: number) => {
|
||||
setSelectedBrands((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.has(brandId) ? newSet.delete(brandId) : newSet.add(brandId);
|
||||
return newSet;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const handleCategoryToggle = useCallback((categoryId: number) => {
|
||||
setSelectedFilterCategories((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.has(categoryId)
|
||||
? newSet.delete(categoryId)
|
||||
: newSet.add(categoryId);
|
||||
return newSet;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const handlePriceChange = useCallback((values: number[]) => {
|
||||
setPriceRange([values[0], values[1]]);
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const handlePriceSortChange = useCallback(
|
||||
(sortType: "none" | "lowToHigh" | "highToLow") => {
|
||||
setPriceSort(sortType);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSelectedBrands(new Set());
|
||||
setSelectedFilterCategories(new Set());
|
||||
setPriceRange([0, 10000]);
|
||||
setPriceSort("none");
|
||||
setCurrentPage(1);
|
||||
setAllProducts([]);
|
||||
}, []);
|
||||
|
||||
const filterTranslations = useMemo(
|
||||
() => ({
|
||||
category: t("category"),
|
||||
brands: t("brands"),
|
||||
sort: t("sort"),
|
||||
default: t("default"),
|
||||
price_low_to_high: t("price_low_to_high"),
|
||||
price_high_to_low: t("price_high_to_low"),
|
||||
price: t("price"),
|
||||
price_from: t("price_from"),
|
||||
price_to: t("price_to"),
|
||||
reset: t("reset"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
if (categoriesLoading) return <div>{t("common.loading")}</div>;
|
||||
if (!selectedCategory)
|
||||
return <div className="text-center py-8">{t("category_not_found")}</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
|
||||
<h2 className="p-4 text-3xl font-bold pb-6 rounded-t-lg mb-0 bg-white">
|
||||
{selectedCategory.name}
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-4 bg-white rounded-b-lg">
|
||||
{/* Desktop Filters Sidebar */}
|
||||
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4">
|
||||
<ScrollArea className="h-auto">
|
||||
<CategoryFilters
|
||||
filtersData={filtersData}
|
||||
selectedBrands={selectedBrands}
|
||||
selectedFilterCategories={selectedFilterCategories}
|
||||
priceSort={priceSort}
|
||||
priceRange={priceRange}
|
||||
onBrandToggle={handleBrandToggle}
|
||||
onCategoryToggle={handleCategoryToggle}
|
||||
onPriceSortChange={handlePriceSortChange}
|
||||
onPriceChange={handlePriceChange}
|
||||
onReset={resetFilters}
|
||||
translations={filterTranslations}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="flex-1 bg-white rounded-lg mb-6">
|
||||
<CategoryProductsGrid
|
||||
products={sortedProducts}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={loadMoreData}
|
||||
translations={{
|
||||
loading: t("common.loading"),
|
||||
no_results: t("no_results"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Filters Sheet */}
|
||||
<CategoryFiltersSheet
|
||||
isOpen={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
filterLabel={t("filter")}
|
||||
closeLabel={t("close")}
|
||||
>
|
||||
<CategoryFilters
|
||||
filtersData={filtersData}
|
||||
selectedBrands={selectedBrands}
|
||||
selectedFilterCategories={selectedFilterCategories}
|
||||
priceSort={priceSort}
|
||||
priceRange={priceRange}
|
||||
onBrandToggle={handleBrandToggle}
|
||||
onCategoryToggle={handleCategoryToggle}
|
||||
onPriceSortChange={handlePriceSortChange}
|
||||
onPriceChange={handlePriceChange}
|
||||
onReset={resetFilters}
|
||||
translations={filterTranslations}
|
||||
/>
|
||||
</CategoryFiltersSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
features/category/components/CategoryProductsGrid.tsx
Normal file
60
features/category/components/CategoryProductsGrid.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import ProductCard from "@/features/home/components/ProductCard";
|
||||
import type { Product } from "@/lib/types/api";
|
||||
|
||||
interface CategoryProductsGridProps {
|
||||
products: Product[];
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
translations: {
|
||||
loading: string;
|
||||
no_results: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CategoryProductsGrid({
|
||||
products,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
translations,
|
||||
}: CategoryProductsGridProps) {
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{translations.no_results}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteScroll
|
||||
dataLength={products.length}
|
||||
next={onLoadMore}
|
||||
hasMore={hasMore}
|
||||
scrollThreshold={0.8}
|
||||
style={{ overflow: "visible" }}
|
||||
loader={
|
||||
<div className="flex justify-center py-4">
|
||||
<div>{translations.loading}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="bg-white rounded-lg grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
price={
|
||||
product.price_amount ? parseFloat(product.price_amount) : null
|
||||
}
|
||||
struct_price_text={`${product.price_amount} TMT`}
|
||||
images={[product.media?.[0]?.images_400x400]}
|
||||
stock={product.stock}
|
||||
button={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function useCategoryProducts(
|
||||
{
|
||||
params: {
|
||||
page: options?.page || 1,
|
||||
limit: options?.limit
|
||||
per_page: options?.limit
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -123,7 +123,7 @@ export function useFilteredCategoryProducts(
|
||||
queryFn: async () => {
|
||||
const params: Record<string, any> = {
|
||||
page: filters.page || 1,
|
||||
limit: filters.limit || 6,
|
||||
per_page: filters.limit || 6,
|
||||
}
|
||||
|
||||
if (filters.brands && filters.brands.length > 0) {
|
||||
@@ -166,10 +166,10 @@ export function useAllCategoryProductsPaginated(
|
||||
}
|
||||
) {
|
||||
const page = options?.page || 1
|
||||
const limit = options?.limit || 6
|
||||
const per_page = options?.limit || 6
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["category", category?.id, "paginated-products", page, limit],
|
||||
queryKey: ["category", category?.id, "paginated-products", page, per_page],
|
||||
queryFn: async () => {
|
||||
if (!category) {
|
||||
return {
|
||||
@@ -186,7 +186,7 @@ export function useAllCategoryProductsPaginated(
|
||||
category.children.forEach((child) => categoryIds.push(child.id))
|
||||
}
|
||||
|
||||
const perCategoryLimit = Math.ceil(limit / categoryIds.length)
|
||||
const perCategoryLimit = Math.ceil(per_page / categoryIds.length)
|
||||
const hasMoreByCategory: Record<number, boolean> = {}
|
||||
let allPageProducts: Product[] = []
|
||||
|
||||
@@ -196,7 +196,7 @@ export function useAllCategoryProductsPaginated(
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
limit: perCategoryLimit
|
||||
per_page: perCategoryLimit
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { Category } from "@/lib/types/api";
|
||||
|
||||
type Props = {
|
||||
@@ -38,8 +37,8 @@ export default function CategoryGrid({
|
||||
<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) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="w-full h-36 rounded-lg" />
|
||||
<Skeleton className="w-full h-4 rounded" />
|
||||
<div className="w-full h-36 bg-gray-200 rounded-lg animate-pulse" />
|
||||
<div className="h-4 bg-gray-200 rounded w-full animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -59,7 +58,9 @@ export default function CategoryGrid({
|
||||
<Card className="hover:shadow-md border-none shadow-none p-0 gap-2 transition-all cursor-pointer">
|
||||
<div className="relative w-full h-36 overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={cat.media?.[0]?.images_400x400 || "/placeholder.svg"}
|
||||
src={
|
||||
cat.media[0]?.thumbnail || cat.media?.[0]?.images_400x400
|
||||
}
|
||||
alt={cat.name}
|
||||
fill
|
||||
className="object-contain"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import HeroCarousel from "./Carousel";
|
||||
import CategoryGrid from "./CategoryGrid";
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
export default function HomePage() {
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("common");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(10);
|
||||
|
||||
const {
|
||||
@@ -23,19 +22,15 @@ export default function HomePage() {
|
||||
isLoading: categoriesLoading,
|
||||
isError: categoriesError,
|
||||
} = useCategories();
|
||||
|
||||
const { data: carousels, isLoading: carouselsLoading } = useCarousels();
|
||||
|
||||
const {
|
||||
data: collections,
|
||||
isLoading: collectionsLoading,
|
||||
isError: collectionsError,
|
||||
} = useCollections();
|
||||
|
||||
// CRITICAL: Prefetch favorites on mount to avoid loading states
|
||||
const { isLoading: favoritesLoading } = useFavorites();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
// Prefetch favorites
|
||||
useFavorites();
|
||||
|
||||
const loadMore = () => {
|
||||
if (collections && visibleCount < collections.length) {
|
||||
@@ -43,22 +38,16 @@ export default function HomePage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted) return <div className="p-8">Loading...</div>;
|
||||
|
||||
const carouselItems =
|
||||
carousels?.map((carousel) => ({
|
||||
title: carousel.title || "",
|
||||
image: carousel.image || carousel.thumbnail,
|
||||
url: carousel.link || null,
|
||||
carousels?.map((c) => ({
|
||||
title: c.title || "",
|
||||
image: c.image || c.thumbnail,
|
||||
url: c.link || null,
|
||||
})) || [];
|
||||
|
||||
const visibleCollections = collections?.slice(0, visibleCount) || [];
|
||||
const hasMore = collections ? visibleCount < collections.length : false;
|
||||
|
||||
// Show loading indicator while favorites are being fetched
|
||||
const showFavoritesLoading =
|
||||
favoritesLoading && !categoriesLoading && !collectionsLoading;
|
||||
|
||||
return (
|
||||
<div className="px-2 md:px-4 lg:px-6 pt-4 pb-12 space-y-8 max-w-[1504px] mx-auto">
|
||||
{!carouselsLoading && carouselItems.length > 0 && (
|
||||
@@ -73,13 +62,6 @@ export default function HomePage() {
|
||||
title={t("categories")}
|
||||
/>
|
||||
|
||||
{showFavoritesLoading && (
|
||||
<div className="text-center py-4">
|
||||
<div className="inline-block h-6 w-6 animate-spin rounded-full border-2 border-solid border-blue-600 border-r-transparent"></div>
|
||||
<p className="text-gray-500 text-sm mt-2">Loading favorites...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{collectionsError ? (
|
||||
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||
<p className="text-red-600">
|
||||
@@ -89,17 +71,18 @@ export default function HomePage() {
|
||||
) : collectionsLoading ? (
|
||||
<div className="space-y-8">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl shadow-sm p-6">
|
||||
<section key={i} className="bg-white rounded-2xl shadow-sm p-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-48 mb-4 animate-pulse" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="h-64 bg-gray-200 rounded-lg animate-pulse"
|
||||
/>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<div key={j} className="space-y-2">
|
||||
<div className="w-full h-[260px] bg-gray-200 rounded-xl animate-pulse" />
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 animate-pulse" />
|
||||
<div className="h-6 bg-gray-200 rounded w-1/2 animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -109,13 +92,13 @@ export default function HomePage() {
|
||||
hasMore={hasMore}
|
||||
loader={
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
|
||||
<p className="text-gray-500 mt-2">Loading more collections...</p>
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent" />
|
||||
<p className="text-gray-500 mt-2">{t("loading")}</p>
|
||||
</div>
|
||||
}
|
||||
endMessage={
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">✓ All collections loaded</p>
|
||||
<p className="text-gray-600">✓ {t("all_collections_loaded")}</p>
|
||||
</div>
|
||||
}
|
||||
scrollThreshold={0.8}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import ProductGridSkeleton from "./ProductGridSkeleton"
|
||||
import CategorySkeleton from "../../category/components/CategorySkeleton"
|
||||
|
||||
export default function HomeSkeleton() {
|
||||
return (
|
||||
<div className="px-4 md:px-8 lg:px-12 pt-8 pb-12 space-y-8">
|
||||
{/* Hero Carousel Skeleton */}
|
||||
<section className="rounded-2xl overflow-hidden">
|
||||
<Skeleton className="w-full h-[200px] sm:h-[300px] md:h-[420px] bg-gray-200" />
|
||||
</section>
|
||||
|
||||
{/* Categories Section Skeleton */}
|
||||
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||
<Skeleton className="h-6 w-32 mb-4 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) => (
|
||||
<CategorySkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Products Section Skeleton */}
|
||||
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||
<Skeleton className="h-6 w-32 mb-4 bg-gray-200" />
|
||||
<ProductGridSkeleton count={10} columns="5" />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, MouseEvent, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, MouseEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Heart, ShoppingCart, Loader2, Plus, Minus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
useCart,
|
||||
} from "@/features/cart/hooks/useCart";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ProductCardProps = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -42,8 +44,6 @@ export default function ProductCard({
|
||||
name,
|
||||
price,
|
||||
struct_price_text,
|
||||
discount,
|
||||
discount_text,
|
||||
images,
|
||||
labels = [],
|
||||
price_color = "#005bff",
|
||||
@@ -52,6 +52,8 @@ export default function ProductCard({
|
||||
button = false,
|
||||
stock,
|
||||
}: ProductCardProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
const { isFavorite, isLoading: isFavoriteLoading } = useIsFavorite(id);
|
||||
const { mutate: toggleFavorite, isPending: isFavoriteToggling } =
|
||||
useToggleFavorite();
|
||||
@@ -66,54 +68,45 @@ export default function ProductCard({
|
||||
|
||||
const autoplayRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||
const isRequestInFlightRef = useRef(false);
|
||||
const isRequestInFlightRef = useRef<boolean>(false);
|
||||
const pendingQuantityRef = useRef<number | null>(null);
|
||||
|
||||
const hasMultipleImages = images.length > 1;
|
||||
|
||||
const cartItem = cartData?.data?.find((item: any) => item.product?.id === id);
|
||||
const isInCart = !!cartItem;
|
||||
const isOutOfStock = stock !== undefined && stock === 0;
|
||||
const isOutOfStock = stock === 0;
|
||||
const availableStock = stock || 999;
|
||||
const t = useTranslations();
|
||||
|
||||
// Carousel setup
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
});
|
||||
const onSelect = () => setCurrent(api.selectedScrollSnap());
|
||||
api.on("select", onSelect);
|
||||
return () => {
|
||||
api.off("select", onSelect);
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
// Autoplay
|
||||
useEffect(() => {
|
||||
if (!api || !hasMultipleImages) return;
|
||||
|
||||
const startAutoplay = () => {
|
||||
autoplayRef.current = setInterval(() => {
|
||||
if (api.canScrollNext()) {
|
||||
api.scrollNext();
|
||||
} else {
|
||||
api.scrollTo(0);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
autoplayRef.current = setInterval(() => {
|
||||
api.canScrollNext() ? api.scrollNext() : api.scrollTo(0);
|
||||
}, 3000);
|
||||
|
||||
startAutoplay();
|
||||
return () => {
|
||||
if (autoplayRef.current) {
|
||||
clearInterval(autoplayRef.current);
|
||||
}
|
||||
if (autoplayRef.current) clearInterval(autoplayRef.current);
|
||||
};
|
||||
}, [api, hasMultipleImages]);
|
||||
|
||||
// Sync localQuantity with cart
|
||||
// Sync local quantity with cart
|
||||
useEffect(() => {
|
||||
if (cartItem?.product_quantity) {
|
||||
setLocalQuantity(cartItem.product_quantity);
|
||||
} else {
|
||||
setLocalQuantity(1);
|
||||
}
|
||||
setLocalQuantity(cartItem?.product_quantity || 1);
|
||||
}, [cartItem]);
|
||||
|
||||
// Server sync function
|
||||
const syncToServer = useCallback(
|
||||
async (quantity: number) => {
|
||||
if (isRequestInFlightRef.current) {
|
||||
@@ -125,13 +118,7 @@ export default function ProductCard({
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
await updateCartMutation.mutateAsync({
|
||||
productId: id,
|
||||
quantity: quantity,
|
||||
});
|
||||
|
||||
isRequestInFlightRef.current = false;
|
||||
setIsSyncing(false);
|
||||
await updateCartMutation.mutateAsync({ productId: id, quantity });
|
||||
await refetchCart();
|
||||
|
||||
if (pendingQuantityRef.current !== null) {
|
||||
@@ -141,9 +128,13 @@ export default function ProductCard({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Sync failed:", error);
|
||||
setLocalQuantity(cartItem?.product_quantity || 1);
|
||||
toast.error("Failed to update quantity", {
|
||||
description: "Please try again",
|
||||
});
|
||||
} finally {
|
||||
isRequestInFlightRef.current = false;
|
||||
setIsSyncing(false);
|
||||
setLocalQuantity(cartItem?.product_quantity || 1);
|
||||
}
|
||||
},
|
||||
[id, updateCartMutation, cartItem, refetchCart]
|
||||
@@ -151,24 +142,17 @@ export default function ProductCard({
|
||||
|
||||
// Debounced sync
|
||||
useEffect(() => {
|
||||
if (!isInCart) return;
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
if (localQuantity === (cartItem?.product_quantity || 1)) {
|
||||
if (!isInCart || localQuantity === (cartItem?.product_quantity || 1))
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
syncToServer(localQuantity);
|
||||
}, 800);
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
|
||||
};
|
||||
}, [localQuantity, isInCart, cartItem, syncToServer]);
|
||||
|
||||
@@ -180,14 +164,11 @@ export default function ProductCard({
|
||||
toggleFavorite(
|
||||
{ productId: id, isFavorite },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
onSuccess: (data) =>
|
||||
toast.success(
|
||||
data.wasAdded ? "Added to favorites" : "Removed from favorites"
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Error. Try again");
|
||||
},
|
||||
),
|
||||
onError: () => toast.error("Error. Try again"),
|
||||
}
|
||||
);
|
||||
},
|
||||
@@ -199,6 +180,16 @@ export default function ProductCard({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Stock kontrolü
|
||||
if (localQuantity > availableStock) {
|
||||
toast.error("Insufficient Stock", {
|
||||
description: `Only ${availableStock} items available in stock`,
|
||||
duration: 4000,
|
||||
});
|
||||
setLocalQuantity(availableStock);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSyncing(true);
|
||||
|
||||
try {
|
||||
@@ -206,58 +197,51 @@ export default function ProductCard({
|
||||
productId: id,
|
||||
quantity: localQuantity,
|
||||
});
|
||||
|
||||
await refetchCart();
|
||||
setIsSyncing(false);
|
||||
|
||||
toast.success("Added to cart", {
|
||||
description: `${name} has been added to your cart`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Add to cart error:", error);
|
||||
setIsSyncing(false);
|
||||
toast.error("Failed to add to cart");
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
},
|
||||
[id, name, localQuantity, addToCartMutation, refetchCart]
|
||||
[id, name, localQuantity, availableStock, addToCartMutation, refetchCart]
|
||||
);
|
||||
|
||||
const handleQuantityIncrease = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
const handleQuantityChange = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>, delta: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (localQuantity >= availableStock) {
|
||||
toast.error("Stock limit reached", {
|
||||
description: `Only ${availableStock} items available`,
|
||||
const newQuantity = localQuantity + delta;
|
||||
|
||||
if (newQuantity < 1) return;
|
||||
|
||||
if (newQuantity > availableStock) {
|
||||
toast.error("Stock Limit Reached", {
|
||||
description: `Maximum ${availableStock} items available`,
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalQuantity((prev) => prev + 1);
|
||||
setLocalQuantity(newQuantity);
|
||||
},
|
||||
[localQuantity, availableStock]
|
||||
);
|
||||
|
||||
const handleQuantityDecrease = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (localQuantity <= 1) return;
|
||||
setLocalQuantity((prev) => prev - 1);
|
||||
},
|
||||
[localQuantity]
|
||||
);
|
||||
|
||||
const handleCardClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
const handleCardClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.closest("button") ||
|
||||
target.closest('[data-carousel-control="true"]')
|
||||
) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
router.push(`/product/${id}`);
|
||||
};
|
||||
|
||||
const handleNavClick = (e: MouseEvent, action: () => void) => {
|
||||
@@ -267,32 +251,27 @@ export default function ProductCard({
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/product/${id}`}
|
||||
className="no-underline flex justify-center"
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="flex justify-center cursor-pointer"
|
||||
>
|
||||
<Card
|
||||
className="relative gap-2 border-none shadow-none p-0 w-full overflow-hidden rounded-2xl cursor-pointer"
|
||||
className="relative gap-2 border-none shadow-none p-0 w-full overflow-hidden rounded-2xl"
|
||||
style={{ height, maxWidth: width }}
|
||||
>
|
||||
<div className="relative w-full h-[260px] group">
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
loop: true,
|
||||
watchDrag: false,
|
||||
}}
|
||||
opts={{ align: "start", loop: true, watchDrag: false }}
|
||||
setApi={setApi}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<CarouselContent className="h-[260px] ml-0">
|
||||
{images.map((image, index) => (
|
||||
<CarouselItem key={index} className="h-[260px] pl-0">
|
||||
{images.map((image, idx) => (
|
||||
<CarouselItem key={idx} className="h-[260px] pl-0">
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<img
|
||||
src={image}
|
||||
alt={`${name} - ${index + 1}`}
|
||||
alt={`${name} - ${idx + 1}`}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
@@ -317,7 +296,6 @@ export default function ProductCard({
|
||||
)}
|
||||
</Carousel>
|
||||
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
onClick={handleFavorite}
|
||||
disabled={isFavoriteToggling || isFavoriteLoading}
|
||||
@@ -325,29 +303,31 @@ export default function ProductCard({
|
||||
>
|
||||
{isFavoriteLoading ? (
|
||||
<div className="w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
|
||||
) : isFavorite ? (
|
||||
<Heart className="w-5 h-5 text-red-500 fill-red-500" />
|
||||
) : (
|
||||
<Heart className="w-5 h-5 text-gray-700" />
|
||||
<Heart
|
||||
className={`w-5 h-5 ${
|
||||
isFavorite ? "text-red-500 fill-red-500" : "text-gray-700"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasMultipleImages && (
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
|
||||
{images.map((_, index) => (
|
||||
{images.map((_, idx) => (
|
||||
<button
|
||||
key={index}
|
||||
key={idx}
|
||||
data-carousel-control="true"
|
||||
onClick={(e) => handleNavClick(e, () => api?.scrollTo(index))}
|
||||
onClick={(e) => handleNavClick(e, () => api?.scrollTo(idx))}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
index === current ? "w-6 bg-white" : "w-1.5 bg-white/60"
|
||||
idx === current ? "w-6 bg-white" : "w-1.5 bg-white/60"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{labels?.length > 0 && (
|
||||
{labels.length > 0 && (
|
||||
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
|
||||
{labels.map((label, idx) => (
|
||||
<Badge
|
||||
@@ -361,7 +341,6 @@ export default function ProductCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Out of Stock Overlay */}
|
||||
{isOutOfStock && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-10">
|
||||
<Badge variant="secondary" className="text-sm font-bold">
|
||||
@@ -383,14 +362,13 @@ export default function ProductCard({
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
{/* Cart controls - show on hover if button enabled */}
|
||||
{button && !isOutOfStock && (
|
||||
<div className=" px-1">
|
||||
<div className="px-1">
|
||||
{!isInCart ? (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isSyncing}
|
||||
className="w-full rounded-lg gap-2 bg-[#005bff] hover:bg-[#0041c4] cursor-pointer"
|
||||
className="w-full rounded-lg gap-2 bg-[#005bff] hover:bg-[#0041c4]"
|
||||
size="sm"
|
||||
>
|
||||
{isSyncing ? (
|
||||
@@ -410,9 +388,9 @@ export default function ProductCard({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleQuantityDecrease}
|
||||
onClick={(e) => handleQuantityChange(e, -1)}
|
||||
disabled={isSyncing || localQuantity <= 1}
|
||||
className="rounded-lg cursor-pointer h-9 w-9 shrink-0"
|
||||
className="rounded-lg h-9 w-9 shrink-0"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -425,9 +403,9 @@ export default function ProductCard({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleQuantityIncrease}
|
||||
onClick={(e) => handleQuantityChange(e, 1)}
|
||||
disabled={localQuantity >= availableStock || isSyncing}
|
||||
className="rounded-lg cursor-pointer h-9 w-9 shrink-0"
|
||||
className="rounded-lg h-9 w-9 shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-[#005bff]" />
|
||||
</Button>
|
||||
@@ -436,6 +414,6 @@ export default function ProductCard({
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card } from "@/components/ui/card"
|
||||
|
||||
export default function ProductCardSkeleton() {
|
||||
return (
|
||||
<Card className="overflow-hidden rounded-xl">
|
||||
{/* Image Skeleton */}
|
||||
<Skeleton className="aspect-square w-full bg-gray-200" />
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Title skeleton - 2 lines */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full bg-gray-200" />
|
||||
<Skeleton className="h-4 w-3/4 bg-gray-200" />
|
||||
</div>
|
||||
|
||||
{/* Price skeleton */}
|
||||
<Skeleton className="h-6 w-1/2 bg-gray-200" />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import ProductCard from "@/features/home/components/ProductCard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useCollectionProducts } from "@/lib/hooks";
|
||||
import type { Collection } from "@/lib/types/api";
|
||||
|
||||
@@ -14,48 +12,42 @@ type Props = {
|
||||
|
||||
export default function CollectionSection({ collection, locale }: Props) {
|
||||
const router = useRouter();
|
||||
const [shouldRender, setShouldRender] = useState(true);
|
||||
|
||||
const {
|
||||
data: productsData,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useCollectionProducts(collection.id, { enabled: shouldRender });
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && productsData) {
|
||||
const hasProducts = productsData.data && productsData.data.length > 0;
|
||||
setShouldRender(hasProducts);
|
||||
}
|
||||
}, [isLoading, productsData]);
|
||||
|
||||
if (!isLoading && (!productsData?.data || productsData.data.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
} = useCollectionProducts(collection.id);
|
||||
|
||||
const handleTitleClick = () => {
|
||||
router.push(`/${locale}/collections/${collection.id}`);
|
||||
};
|
||||
|
||||
// Hide section if no products
|
||||
if (!isLoading && (!productsData?.data || productsData.data.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="bg-white rounded-2xl shadow-sm p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="h-6 w-6 bg-gray-200 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="w-full h-64 rounded-lg" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="w-full h-[260px] bg-gray-200 rounded-xl animate-pulse" />
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 animate-pulse mx-2" />
|
||||
<div className="h-6 bg-gray-200 rounded w-1/2 animate-pulse mx-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return null;
|
||||
}
|
||||
if (isError) return null;
|
||||
|
||||
const displayProducts = productsData?.data.slice(0, 10) || [];
|
||||
|
||||
@@ -75,11 +67,11 @@ export default function CollectionSection({ collection, locale }: Props) {
|
||||
{displayProducts.map((product) => {
|
||||
const allImages = product.media
|
||||
?.map(
|
||||
(media) =>
|
||||
media.images_800x800 ||
|
||||
media.images_720x720 ||
|
||||
media.images_400x400 ||
|
||||
media.thumbnail
|
||||
(m) =>
|
||||
m.images_800x800 ||
|
||||
m.images_720x720 ||
|
||||
m.images_400x400 ||
|
||||
m.thumbnail
|
||||
)
|
||||
.filter(Boolean) || ["/placeholder-product.jpg"];
|
||||
|
||||
@@ -101,7 +93,6 @@ export default function CollectionSection({ collection, locale }: Props) {
|
||||
price_color="#0059ff"
|
||||
height={360}
|
||||
width={250}
|
||||
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import ProductCardSkeleton from "./ProductCardSkeleton"
|
||||
|
||||
interface ProductGridSkeletonProps {
|
||||
count?: number
|
||||
columns?: "2" | "3" | "4" | "5"
|
||||
}
|
||||
|
||||
export default function ProductGridSkeleton({ count = 8, columns = "4" }: ProductGridSkeletonProps) {
|
||||
const gridClass =
|
||||
{
|
||||
"2": "grid-cols-2",
|
||||
"3": "md:grid-cols-3",
|
||||
"4": "md:grid-cols-4 lg:grid-cols-4",
|
||||
"5": "md:grid-cols-4 xl:grid-cols-5",
|
||||
}[columns] || "md:grid-cols-4"
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 sm:grid-cols-3 ${gridClass} gap-4`}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<ProductCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -191,8 +191,8 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 min-h-screen">
|
||||
<h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1>
|
||||
<div className="container mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen">
|
||||
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-6">{t("my_orders")}</h1>
|
||||
|
||||
<Tabs defaultValue="active" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
@@ -314,10 +314,10 @@ function CompactOrderCard({
|
||||
const itemCount = order.orderItems.length;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden transition-all hover:shadow-md">
|
||||
<Card className="overflow-hidden transition-all py-2 md:py-4 lg:py-6 hover:shadow-md">
|
||||
{/* Compact Header - Always Visible */}
|
||||
<div
|
||||
className="p-4 mx-4 rounded-lg cursor-pointer bg-linear-to-r from-white to-gray-50 hover:from-gray-50 hover:to-gray-100 transition-colors"
|
||||
className="p-2 md:p-4 mx-2 md:mx-4 rounded-lg cursor-pointer bg-linear-to-r from-white to-gray-50 hover:from-gray-50 hover:to-gray-100 transition-colors"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -325,7 +325,7 @@ function CompactOrderCard({
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-gray-500" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
<h3 className="font-semibold text-base lg:text-lg">
|
||||
{t("order_number")} {order.id}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
@@ -336,12 +336,15 @@ function CompactOrderCard({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex flex-col md:flex-row gap-2 ">
|
||||
|
||||
{getStatusBadge(order.status)}
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-lg text-green-600">
|
||||
{total.toFixed(2)} TMT
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
|
||||
@@ -432,8 +432,8 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-2 md:px-4 lg:px-6 rounded-lg pb-12 space-y-8 max-w-[1504px] mx-auto">
|
||||
<div className="flex flex-col lg:flex-row gap-8 bg-white p-4">
|
||||
<div className="px-2 md:px-4 lg:px-6 rounded-lg mb-18 space-y-8 max-w-[1504px] mx-auto">
|
||||
<div className="flex flex-col lg:flex-row gap-8 rounded-b-lg bg-white p-4">
|
||||
<div className="flex-1 max-w-2xl">
|
||||
<div className="relative">
|
||||
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-white">
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useCallback, useMemo, useState, useEffect } from "react";
|
||||
import { LogOut, Edit2, Save, X, User, Phone, MapPin, Mail } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useUserProfile } from "@/lib/hooks";
|
||||
import { useUserProfile, useUpdateProfile } from "@/lib/hooks";
|
||||
import { clearAuthToken } from "@/lib/api";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ProfilePageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -17,34 +24,118 @@ interface ProfilePageProps {
|
||||
|
||||
export default function ClientProfilePage(props: ProfilePageProps) {
|
||||
const { data: user, isLoading, error } = useUserProfile();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const t = useTranslations();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
phone_number: "",
|
||||
address: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user && !isEditing) {
|
||||
setFormData({
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
phone_number: user.phone_number || "",
|
||||
address: user.address || "",
|
||||
});
|
||||
}
|
||||
}, [user, isEditing]);
|
||||
|
||||
const prepareDataForAPI = useCallback(() => {
|
||||
return {
|
||||
name: `${formData.first_name} ${formData.last_name}`.trim(),
|
||||
phone_number: formData.phone_number,
|
||||
address: formData.address,
|
||||
};
|
||||
}, [formData]);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
clearAuthToken();
|
||||
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>
|
||||
const handleEdit = useCallback(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
phone_number: user.phone_number || "",
|
||||
address: user.address || "",
|
||||
});
|
||||
setIsEditing(true);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
if (user) {
|
||||
setFormData({
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
phone_number: user.phone_number || "",
|
||||
address: user.address || "",
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const apiData = prepareDataForAPI();
|
||||
|
||||
if (!apiData.name) {
|
||||
toast.error(t("name_required") || "Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProfile.mutateAsync(apiData);
|
||||
setIsEditing(false);
|
||||
toast.success(t("profile_updated_success") || "Profile updated successfully");
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.message || t("profile_update_error") || "Failed to update profile";
|
||||
toast.error(errorMessage);
|
||||
console.error("Profile update error:", err);
|
||||
}
|
||||
}, [formData, updateProfile, t, prepareDataForAPI]);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(field: keyof typeof formData, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const loadingSkeleton = useMemo(
|
||||
() => (
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8 pt-20 sm:pt-24">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<Skeleton className="h-8 sm:h-10 w-32 sm:w-40 mb-2" />
|
||||
<Skeleton className="h-4 w-48 sm:w-64" />
|
||||
</div>
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32 mb-2" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 sm:space-y-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-10 sm:h-11 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), []);
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return loadingSkeleton;
|
||||
@@ -53,10 +144,20 @@ export default function ClientProfilePage(props: ProfilePageProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<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 shadow-sm">
|
||||
<CardContent className="pt-6 text-center">
|
||||
<p className="text-red-600 mb-4">{t("error_loading_profile")}</p>
|
||||
<Button onClick={() => window.location.reload()}>{t("try_again")}</Button>
|
||||
<div className="w-12 h-12 sm:w-14 sm:h-14 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<X className="h-6 w-6 sm:h-7 sm:w-7 text-red-600" />
|
||||
</div>
|
||||
<p className="text-red-600 mb-4 text-sm sm:text-base">
|
||||
{t("error_loading_profile")}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -64,51 +165,194 @@ export default function ClientProfilePage(props: ProfilePageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4 pt-20">
|
||||
<div className="container mx-auto max-w-2xl">
|
||||
<h1 className="text-3xl font-bold mb-6">{t("profile")}</h1>
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8 pt-20 sm:pt-24">
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
{/* Header Section */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900 mb-1 sm:mb-2 truncate">
|
||||
{t("profile")}
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
{isEditing ? t("edit_your_information") : t("view_your_information")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0 w-12 h-12 sm:w-14 sm:h-14 bg-blue-600 rounded-full flex items-center justify-center shadow-sm">
|
||||
<User className="h-6 w-6 sm:h-7 sm:w-7 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-lg mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("personal_info")}</CardTitle>
|
||||
<CardDescription>{t("profile_description")}</CardDescription>
|
||||
{/* Profile Card */}
|
||||
<Card className="shadow-sm border border-gray-200 mb-4 sm:mb-6">
|
||||
<CardHeader className="border-b border-gray-100 pb-4 sm:pb-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-lg sm:text-xl text-gray-900">
|
||||
{t("personal_info")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs sm:text-sm text-gray-600 mt-1">
|
||||
{t("profile_description")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start sm:self-center border-gray-300 hover:bg-gray-50 text-gray-700 h-9"
|
||||
>
|
||||
<Edit2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 mr-1.5 sm:mr-2" />
|
||||
<span className="text-sm">{t("edit")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
<CardContent className="pt-5 sm:pt-6 space-y-4 sm:space-y-5">
|
||||
{user && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">{t("first_name")}</Label>
|
||||
<Input id="firstName" value={user.first_name || ""} disabled className="bg-gray-50" />
|
||||
{/* Name Fields - Grid on larger screens */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="firstName"
|
||||
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<User className="h-3.5 w-3.5 text-gray-400" />
|
||||
{t("first_name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
value={formData.first_name}
|
||||
onChange={(e) =>
|
||||
handleInputChange("first_name", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className={`h-10 sm:h-11 text-sm sm:text-base ${
|
||||
isEditing
|
||||
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
|
||||
: "bg-gray-50 border-gray-200 text-gray-700"
|
||||
}`}
|
||||
placeholder={t("enter_first_name")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="lastName"
|
||||
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<User className="h-3.5 w-3.5 text-gray-400" />
|
||||
{t("last_name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
value={formData.last_name}
|
||||
onChange={(e) =>
|
||||
handleInputChange("last_name", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className={`h-10 sm:h-11 text-sm sm:text-base ${
|
||||
isEditing
|
||||
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
|
||||
: "bg-gray-50 border-gray-200 text-gray-700"
|
||||
}`}
|
||||
placeholder={t("enter_last_name")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phone Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lastName">{t("last_name")}</Label>
|
||||
<Input id="lastName" value={user.last_name || ""} disabled className="bg-gray-50" />
|
||||
<Label
|
||||
htmlFor="phone"
|
||||
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<Phone className="h-3.5 w-3.5 text-gray-400" />
|
||||
{t("phone_number")}
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone_number}
|
||||
onChange={(e) =>
|
||||
handleInputChange("phone_number", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className={`h-10 sm:h-11 text-sm sm:text-base ${
|
||||
isEditing
|
||||
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
|
||||
: "bg-gray-50 border-gray-200 text-gray-700"
|
||||
}`}
|
||||
placeholder={t("enter_phone_number")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Address Field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">{t("phone_number")}</Label>
|
||||
<Input id="phone" value={user.phone_number || ""} disabled className="bg-gray-50" />
|
||||
<Label
|
||||
htmlFor="address"
|
||||
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<MapPin className="h-3.5 w-3.5 text-gray-400" />
|
||||
{t("address")}
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) =>
|
||||
handleInputChange("address", e.target.value)
|
||||
}
|
||||
disabled={!isEditing}
|
||||
className={`h-10 sm:h-11 text-sm sm:text-base ${
|
||||
isEditing
|
||||
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
|
||||
: "bg-gray-50 border-gray-200 text-gray-700"
|
||||
}`}
|
||||
placeholder={t("enter_address")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">{t("address")}</Label>
|
||||
<Input id="address" value={user.address || ""} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
{/* Action Buttons - Edit Mode */}
|
||||
{isEditing && (
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-4 sm:pt-5 border-t border-gray-100">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateProfile.isPending}
|
||||
className="w-full sm:flex-1 bg-blue-600 hover:bg-blue-700 h-10 sm:h-11 text-sm sm:text-base font-medium shadow-sm"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{updateProfile.isPending ? t("saving") : t("save_changes")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="outline"
|
||||
disabled={updateProfile.isPending}
|
||||
className="w-full sm:flex-1 h-10 sm:h-11 text-sm sm:text-base font-medium border-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
className="w-full max-w-md flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
{t("common.logout")}
|
||||
</Button>
|
||||
{/* Logout Button */}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
className="w-full sm:w-auto sm:min-w-[280px] flex items-center justify-center gap-2 h-11 text-sm sm:text-base font-medium shadow-sm"
|
||||
>
|
||||
<LogOut className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
{t("common.logout")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
"send": "Отправить",
|
||||
"enterPhone": "Введите свой номер телефона",
|
||||
"weWillSendCode": "Мы вышлем вам код",
|
||||
"loading": "Загрузка..."
|
||||
"loading": "Загрузка...",
|
||||
"all_collections_loaded": "Все коллекции загружены"
|
||||
},
|
||||
"category": "Категория",
|
||||
"checkout": "Оформить заказ",
|
||||
@@ -26,6 +27,7 @@
|
||||
"total_price": "Общая цена:",
|
||||
"profile": "Профиль",
|
||||
"cart_orders": "Корзина заказов",
|
||||
"shipping_method": "Способ доставки",
|
||||
"product_description_title": "Описание к товару",
|
||||
"recommended": "Рекомендуем также",
|
||||
"address_search": "Поиск адреса",
|
||||
@@ -148,5 +150,9 @@
|
||||
"only_left": "Осталось {count} шт.",
|
||||
"stock_limit_title": "Недостаточно на складе",
|
||||
"stock_limit_message": "{product} закончился. Можно купить только {stock} шт.",
|
||||
"understood": "Понятно"
|
||||
"understood": "Понятно",
|
||||
"loading": "Загрузка...",
|
||||
"customer_information": "Информация о клиенте",
|
||||
"name": "Имя"
|
||||
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
"send": "Ugrat",
|
||||
"enterPhone": "Telefon belgisini giriziň",
|
||||
"weWillSendCode": "Biz size kod ugradarys",
|
||||
"loading": "Ýüklenýär..."
|
||||
"loading": "Ýüklenýär...",
|
||||
"all_collections_loaded": "Bütüň koleksiyonlar ýüklendi"
|
||||
},
|
||||
"category": "Bölümler",
|
||||
"checkout": "Sargyt et",
|
||||
@@ -25,6 +26,7 @@
|
||||
"discount": "Arzanladyş:",
|
||||
"total_price": "Jemi baha:",
|
||||
"profile": "Profil",
|
||||
"shipping_method": "Eltip bermek usuly",
|
||||
"cart_orders": "Sargyt sebedi",
|
||||
"product_description_title": "Haryt barada maglumat",
|
||||
"recommended": "Maslahat berilýän harytlar",
|
||||
@@ -148,5 +150,12 @@
|
||||
"only_left": "Diňe {count} sany galdy",
|
||||
"stock_limit_title": "Çäkli sanda",
|
||||
"stock_limit_message": "{product} harytdan diňe {stock} sany bar. Mundan köp sebediňize goşup bilmersiňiz.",
|
||||
"understood": "Düşündim"
|
||||
"understood": "Düşündim",
|
||||
"loading": "Ýüklenýär...",
|
||||
"customer_information": "Müşteri maglumatlary",
|
||||
"name": "Ady"
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export interface ProductMedia {
|
||||
images_720x720: string;
|
||||
images_800x800: string;
|
||||
images_1200x1200: string;
|
||||
|
||||
}
|
||||
|
||||
export type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP";
|
||||
|
||||
Reference in New Issue
Block a user