fixed some ui, refactored code

This commit is contained in:
Jelaletdin12
2025-12-13 00:05:43 +05:00
parent 5085c0cffd
commit 633a3c9d47
30 changed files with 1274 additions and 923 deletions

View File

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

View File

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

View File

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

View File

@@ -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} />;
}

View File

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

View File

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

View File

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

View File

@@ -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}
/>
</>
);
}

View File

@@ -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}>
{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">{isAuthenticated ? t.profile : t.login}</span>
<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>
</>
)
);
}

View File

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

View File

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

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
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>
);
}

View File

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

View File

@@ -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 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 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>
</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}
/>
);
})}

View File

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

View File

@@ -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" />
) : (

View File

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

View File

@@ -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">
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">
<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 w-full" />
<Skeleton className="h-10 sm:h-11 w-full" />
</div>
))}
</CardContent>
</Card>
</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,52 +165,195 @@ 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 && (
<>
{/* 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">{t("first_name")}</Label>
<Input id="firstName" value={user.first_name || ""} disabled className="bg-gray-50" />
<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">{t("last_name")}</Label>
<Input id="lastName" value={user.last_name || ""} disabled className="bg-gray-50" />
<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="phone">{t("phone_number")}</Label>
<Input id="phone" value={user.phone_number || ""} 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="address">{t("address")}</Label>
<Input id="address" value={user.address || ""} 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>
{/* 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>
{/* Logout Button */}
<div className="flex justify-center">
<Button
onClick={handleLogout}
variant="destructive"
size="lg"
className="w-full max-w-md flex items-center justify-center gap-2"
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-5 w-5" />
<LogOut className="h-4 w-4 sm:h-5 sm:w-5" />
{t("common.logout")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -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": "Имя"
}

View File

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

View File

@@ -9,6 +9,7 @@ export interface ProductMedia {
images_720x720: string;
images_800x800: string;
images_1200x1200: string;
}
export type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP";