fixed favorites api

This commit is contained in:
Jelaletdin12
2025-12-09 14:59:20 +05:00
parent 2857d34f4d
commit d6c163dd06
21 changed files with 467 additions and 147 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -26,7 +26,8 @@ export default function FavoritesPage() {
useRemoveFromFavorites(); useRemoveFromFavorites();
const { mutate: addToCart, isPending: isAddingToCart } = useAddToCart(); const { mutate: addToCart, isPending: isAddingToCart } = useAddToCart();
const handleRemoveFromFavorites = useCallback((productId: number) => { const handleRemoveFromFavorites = useCallback(
(productId: number) => {
removeFromFavorites(productId, { removeFromFavorites(productId, {
onSuccess: () => { onSuccess: () => {
toast({ toast({
@@ -41,9 +42,12 @@ export default function FavoritesPage() {
}); });
}, },
}); });
}, [removeFromFavorites, toast, t]); },
[removeFromFavorites, toast, t]
);
const handleAddToCart = useCallback((productId: number) => { const handleAddToCart = useCallback(
(productId: number) => {
addToCart( addToCart(
{ productId }, { productId },
{ {
@@ -61,9 +65,12 @@ export default function FavoritesPage() {
}, },
} }
); );
}, [addToCart, toast, t]); },
[addToCart, toast, t]
);
const loadingSkeleton = useMemo(() => ( const loadingSkeleton = useMemo(
() => (
<div className="container mx-auto px-4 py-8 min-h-screen"> <div className="container mx-auto px-4 py-8 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t("favorite_products")}</h1> <h1 className="text-3xl font-bold mb-6">{t("favorite_products")}</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
@@ -72,7 +79,9 @@ export default function FavoritesPage() {
))} ))}
</div> </div>
</div> </div>
), [t]); ),
[t]
);
if (isLoading) { if (isLoading) {
return loadingSkeleton; return loadingSkeleton;
@@ -95,7 +104,7 @@ export default function FavoritesPage() {
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{favorites.map((favorite: Favorite) => ( {favorites.map((favorite: Favorite) => (
<ProductCard <ProductCard
key={favorite.created_at} key={favorite.product.id}
productId={favorite.product.id} productId={favorite.product.id}
product={favorite.product} product={favorite.product}
onRemove={() => handleRemoveFromFavorites(favorite.product.id)} onRemove={() => handleRemoveFromFavorites(favorite.product.id)}
@@ -170,7 +179,6 @@ function ProductCard({
> >
<Link href={`/product/${productId || product.slug}`} className="block"> <Link href={`/product/${productId || product.slug}`} className="block">
<div className="relative aspect-square bg-gray-50"> <div className="relative aspect-square bg-gray-50">
{/* Favorite Button */}
<button <button
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
@@ -183,7 +191,6 @@ function ProductCard({
<Heart className="h-5 w-5 fill-red-500 text-red-500" /> <Heart className="h-5 w-5 fill-red-500 text-red-500" />
</button> </button>
{/* Product Image */}
<Image <Image
src={imageUrl} src={imageUrl}
alt={product.name} alt={product.name}
@@ -193,7 +200,6 @@ function ProductCard({
priority={false} priority={false}
/> />
{/* Out of Stock Badge */}
{product.stock === 0 && ( {product.stock === 0 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center"> <div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Badge variant="secondary" className="text-sm"> <Badge variant="secondary" className="text-sm">
@@ -203,7 +209,6 @@ function ProductCard({
)} )}
</div> </div>
{/* Product Info */}
<div className="p-3"> <div className="p-3">
<h3 className="font-medium text-sm line-clamp-2 mb-2 min-h-[40px]"> <h3 className="font-medium text-sm line-clamp-2 mb-2 min-h-[40px]">
{product.name} {product.name}
@@ -217,7 +222,6 @@ function ProductCard({
</div> </div>
</Link> </Link>
{/* Add to Cart Button - показывается при hover */}
{isHovered && product.stock > 0 && ( {isHovered && product.stock > 0 && (
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-white via-white to-transparent"> <div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-white via-white to-transparent">
<Button <Button

View File

@@ -45,7 +45,7 @@
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: #eff3f6;
--foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card-foreground: oklch(0.141 0.005 285.823);
@@ -120,3 +120,73 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* Animasyonları "utilities" katmanına ekliyoruz ki Tailwind sınıfları gibi davranabilsinler */
@layer utilities {
/* Özel Renk Sınıfları (CSS değişkenlerini kullanmak için) */
.text-fg { color: var(--fg); }
.bg-bg { background-color: var(--bg); }
.stroke-primary { stroke: #005bff; }
/* Dark mode track rengi için özel sınıf */
.stroke-track {
stroke: hsla(var(--hue), 10%, 10%, 0.1);
transition: stroke var(--trans-dur);
}
@media (prefers-color-scheme: dark) {
.stroke-track {
stroke: hsla(var(--hue), 10%, 90%, 0.1);
}
}
/* Animasyon Sınıfları */
.animate-msg { animation: msg 0.3s 13.7s linear forwards; }
.animate-msgLast { animation: msg 0.3s 14s linear reverse forwards; }
.animate-cartLines { animation: cartLines 2s ease-in-out infinite; }
.animate-cartTop { animation: cartTop 2s ease-in-out infinite; }
.animate-cartWheel1 {
animation: cartWheel1 2s ease-in-out infinite;
transform: rotate(-0.25turn);
transform-origin: 43px 111px;
}
.animate-cartWheel2 {
animation: cartWheel2 2s ease-in-out infinite;
transform: rotate(0.25turn);
transform-origin: 102px 111px;
}
.animate-cartWheelStroke { animation: cartWheelStroke 2s ease-in-out infinite; }
}
/* Keyframes Tanımları */
@keyframes msg {
from { opacity: 1; visibility: visible; }
99.9% { opacity: 0; visibility: visible; }
to { opacity: 0; visibility: hidden; }
}
@keyframes cartLines {
from, to { opacity: 0; }
8%, 92% { opacity: 1; }
}
@keyframes cartTop {
from { stroke-dashoffset: -338; }
50% { stroke-dashoffset: 0; }
to { stroke-dashoffset: 338; }
}
@keyframes cartWheel1 {
from { transform: rotate(-0.25turn); }
to { transform: rotate(2.75turn); }
}
@keyframes cartWheel2 {
from { transform: rotate(0.25turn); }
to { transform: rotate(3.25turn); }
}
@keyframes cartWheelStroke {
from, to { stroke-dashoffset: 81.68; }
50% { stroke-dashoffset: 40.84; }
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,80 @@
import React from "react";
const Preloader: React.FC = () => {
return (
// bg-bg ve text-fg bizim CSS'te tanımladığımız değişkenleri kullanır.
// Standart Tailwind sınıflarını (flex, min-h-screen) düzen için kullanıyoruz.
<div className="flex flex-col items-center justify-center min-h-screen text-fg font-sans transition-colors duration-300">
<div className="text-center max-w-[20em] w-full">
{/* SVG Konteyner */}
<svg
className="block mx-auto mb-6 w-32 h-32"
role="img"
aria-label="Shopping cart line animation"
viewBox="0 0 128 128"
xmlns="http://www.w3.org/2000/svg"
>
<g
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="8"
>
{/* Track (Arka plan izleri) */}
<g className="stroke-track">
<polyline points="4,4 21,4 26,22 124,22 112,64 35,64 39,80 106,80" />
<circle cx="43" cy="111" r="13" />
<circle cx="102" cy="111" r="13" />
</g>
{/* Hareketli Çizgiler */}
{/* animate-cartLines sınıfı globals.css'ten geliyor */}
<g className="stroke-primary animate-cartLines">
<polyline
className="animate-cartTop"
points="4,4 21,4 26,22 124,22 112,64 35,64 39,80 106,80"
strokeDasharray="338 338"
strokeDashoffset="-338"
/>
<g className="animate-cartWheel1">
<circle
className="animate-cartWheelStroke"
cx="43"
cy="111"
r="13"
strokeDasharray="81.68 81.68"
strokeDashoffset="81.68"
/>
</g>
<g className="animate-cartWheel2">
<circle
className="animate-cartWheelStroke"
cx="102"
cy="111"
r="13"
strokeDasharray="81.68 81.68"
strokeDashoffset="81.68"
/>
</g>
</g>
</g>
</svg>
{/* Yükleniyor Yazıları */}
<div className="relative h-6">
<p className="absolute w-full animate-msg text-lg">
Bringing you the goods
</p>
<p className="absolute w-full opacity-0 invisible animate-msgLast text-lg">
This is taking long. Somethings wrong.
</p>
</div>
</div>
</div>
);
};
export default Preloader;

66
components/icons.tsx Normal file
View File

@@ -0,0 +1,66 @@
export const FavoriteIcon = () => (
<svg
fill="gray"
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
data-testid="FavoriteBorderIcon"
viewBox="0 0 24 24"
>
<path d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3m-4.4 15.55-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05" />
</svg>
);
export const OrderIcon = () => (
<svg
fill="gray"
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
data-testid="LocalShippingIcon"
viewBox="0 0 24 24"
>
<path d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5m13.5-9 1.96 2.5H17V9.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5" />
</svg>
);
export const CartIcon = () => (
<svg
fill="gray"
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
data-testid="ShoppingBasketIcon"
viewBox="0 0 24 24"
>
<path d="m17.21 9-4.38-6.56c-.19-.28-.51-.42-.83-.42s-.64.14-.83.43L6.79 9H2c-.55 0-1 .45-1 1 0 .09.01.18.04.27l2.54 9.27c.23.84 1 1.46 1.92 1.46h13c.92 0 1.69-.62 1.93-1.46l2.54-9.27L23 10c0-.55-.45-1-1-1zM9 9l3-4.4L15 9zm3 8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2" />
</svg>
);
export const CategoryIcon = () => (
<svg
fill="white"
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-nfmerv"
data-testid="WidgetsIcon"
viewBox="0 0 24 24"
>
<path d="M13 13v8h8v-8zM3 21h8v-8H3zM3 3v8h8V3zm13.66-1.31L11 7.34 16.66 13l5.66-5.66z" />
</svg>
);
export const SearchIcon = () => (
<svg
fill="white"
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
data-testid="SearchIcon"
viewBox="0 0 20 20"
>
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14" />
</svg>
);
export const ProfileIcon = () => (
<svg
fill="gray"
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-1sekacc"
data-testid="FaceIcon"
viewBox="0 0 24 24"
>
<path d="M9 11.75a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5m6 0a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8 0-.29.02-.58.05-.86 2.36-1.05 4.23-2.98 5.21-5.37a9.97 9.97 0 0 0 10.41 3.97c.21.71.33 1.47.33 2.26 0 4.41-3.59 8-8 8" />
</svg>
);

View File

@@ -11,7 +11,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import Logo from "@/public/logo.png"; import Logo from "@/public/logo.webp";
import CategoryMenu from "./ui/CategoryMenu"; import CategoryMenu from "./ui/CategoryMenu";
import SearchBar from "./ui/SearchBar"; import SearchBar from "./ui/SearchBar";
import AuthDialog from "./ui/AuthDialog"; import AuthDialog from "./ui/AuthDialog";
@@ -19,6 +19,7 @@ import ActionButtons from "./ui/ActionButtons";
import LanguageSelector from "./ui/LanguageSelector"; import LanguageSelector from "./ui/LanguageSelector";
import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth"; import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { CategoryIcon } from "../icons";
interface HeaderProps { interface HeaderProps {
locale?: string; locale?: string;
@@ -76,7 +77,7 @@ export default function Header({ locale = "ru" }: HeaderProps) {
className="hidden gap-2 rounded-xl font-bold sm:flex hover:bg-[#005bff] bg-[#005bff] text-white" className="hidden gap-2 rounded-xl font-bold sm:flex hover:bg-[#005bff] bg-[#005bff] text-white"
size="lg" size="lg"
> >
{isCategoryOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />} {isCategoryOpen ? <X className="h-5 w-5" /> : <CategoryIcon />}
{t("common.catalog")} {t("common.catalog")}
</Button> </Button>

View File

@@ -16,6 +16,7 @@ import { useCart, useFavorites, useOrders } from "@/lib/hooks";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useLogout } from "@/lib/hooks/useAuth"; import { useLogout } from "@/lib/hooks/useAuth";
import { CartIcon, FavoriteIcon, OrderIcon, ProfileIcon } from "@/components/icons";
interface ActionButtonsProps { interface ActionButtonsProps {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -70,21 +71,21 @@ export default function ActionButtons({
const buttons: ActionButtonData[] = useMemo(() => [ const buttons: ActionButtonData[] = useMemo(() => [
{ {
icon: <Truck className="h-5 w-5 text-gray-600" />, icon: <OrderIcon />,
label: t("common.orders"), label: t("common.orders"),
href: "/orders", href: "/orders",
badgeCount: ordersCount, badgeCount: ordersCount,
isLoading: ordersLoading, isLoading: ordersLoading,
}, },
{ {
icon: <Heart className="h-5 w-5 text-gray-600" />, icon: <FavoriteIcon />,
label: t("common.favorites"), label: t("common.favorites"),
href: "/favorites", href: "/favorites",
badgeCount: favoritesCount, badgeCount: favoritesCount,
isLoading: favoritesLoading, isLoading: favoritesLoading,
}, },
{ {
icon: <ShoppingCart className="h-5 w-5 text-gray-600" />, icon: <CartIcon />,
label: t("common.cart"), label: t("common.cart"),
href: "/cart", href: "/cart",
badgeCount: cartCount, badgeCount: cartCount,
@@ -101,7 +102,7 @@ export default function ActionButtons({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2"> <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" /> <ProfileIcon />
<span className="text-xs text-gray-700">{t("profile")}</span> <span className="text-xs text-gray-700">{t("profile")}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -118,7 +119,7 @@ export default function ActionButtons({
</DropdownMenu> </DropdownMenu>
) : ( ) : (
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2" onClick={onAuthClick}> <Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2" onClick={onAuthClick}>
<User className="h-5 w-5 text-gray-600" /> <ProfileIcon />
<span className="text-xs text-gray-700">{t("common.login")}</span> <span className="text-xs text-gray-700">{t("common.login")}</span>
</Button> </Button>
)} )}

View File

@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { toast } from "sonner"; import { toast } from "sonner";
import Logo from "@/public/logo.png"; import Logo from "@/public/logo.webp";
import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth"; import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";

View File

@@ -13,6 +13,7 @@ import {
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSearchProducts } from "@/features/search/hooks/useSearch"; import { useSearchProducts } from "@/features/search/hooks/useSearch";
import Image from "next/image"; import Image from "next/image";
import { SearchIcon } from "@/components/icons";
interface SearchBarProps { interface SearchBarProps {
isMobile: boolean; isMobile: boolean;
@@ -158,7 +159,7 @@ export default function SearchBar({
size="icon" size="icon"
className="h-auto hover:bg-[#005bff] cursor-pointer bg-transparent flex items-center mr-1.5 text-white" className="h-auto hover:bg-[#005bff] cursor-pointer bg-transparent flex items-center mr-1.5 text-white"
> >
<Search className="h-5 w-5" /> <SearchIcon />
</Button> </Button>
<SearchResults /> <SearchResults />
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useEffect, type ReactNode } from "react";
import { useRouter, usePathname } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import { useAuthStatus, useGetGuestToken } from "@/lib/hooks/useAuth"; import { useAuthStatus, useGetGuestToken } from "@/lib/hooks/useAuth";
import { useUserProfile } from "@/features/profile/hooks/useUserProfile"; import { useUserProfile } from "@/features/profile/hooks/useUserProfile";
import Preloader from "@/components/PageLoader/PreLoader";
interface AuthWrapperProps { interface AuthWrapperProps {
children: ReactNode; children: ReactNode;
@@ -58,12 +59,7 @@ export default function AuthWrapper({
if (isLoading || (requireAuth && !isAuthenticated)) { if (isLoading || (requireAuth && !isAuthenticated)) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <Preloader/>
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
<p className="text-sm text-gray-600">Yükleniyor...</p>
</div>
</div>
); );
} }

View File

@@ -2,69 +2,148 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api"; import { apiClient } from "@/lib/api";
import type { Favorite } from "@/lib/types/api"; import type { Favorite } from "@/lib/types/api";
// Response tiplerini tanımlayalım
interface FavoritesResponse { interface FavoritesResponse {
data?: Favorite[]; data?: Favorite[];
[key: string]: any; [key: string]: any;
} }
interface FavoriteActionResponse {
data?: string | Favorite[];
[key: string]: any;
}
// Response'u transform eden yardımcı fonksiyon
function transformFavoritesResponse(response: any): Favorite[] { function transformFavoritesResponse(response: any): Favorite[] {
if (typeof response === "object" && response.data) { if (typeof response === "object" && response.data) {
return response.data; return response.data;
} }
if (typeof response === "string") { if (typeof response === "string") {
try { try {
const parsed = JSON.parse(response); const parsed = JSON.parse(response);
return parsed.data || []; return parsed.data || [];
} catch (error) { } catch {
console.error("Failed to parse favorites response:", error);
return []; return [];
} }
} }
return []; return [];
} }
function transformActionResponse(response: any, defaultValue: string): string { // Fetch ALL favorite products (handle pagination on backend)
if (typeof response === "object" && response.data) { async function fetchAllFavorites(): Promise<Favorite[]> {
return response.data; const allFavorites: Favorite[] = [];
} let currentPage = 1;
let hasMorePages = true;
if (typeof response === "string") { while (hasMorePages) {
try { try {
const parsed = JSON.parse(response); const response = await apiClient.get("/favorites", {
return parsed.data || defaultValue; params: { page: currentPage, perPage: 100 },
} catch (error) { });
if (response.includes("<!doctype html>")) {
return defaultValue; const favorites = transformFavoritesResponse(response.data);
allFavorites.push(...favorites);
// Check pagination
const pagination = response.data?.pagination;
if (pagination?.next_page_url) {
currentPage++;
} else {
hasMorePages = false;
} }
console.error(`Failed to parse favorite response:`, error); } catch (error) {
return defaultValue; // If pagination not supported, return what we have
hasMorePages = false;
} }
} }
return defaultValue; return allFavorites;
} }
// Get all favorites with automatic pagination
export function useFavorites() { export function useFavorites() {
return useQuery({ return useQuery({
queryKey: ["favorites"], queryKey: ["favorites"],
queryFn: async () => { queryFn: fetchAllFavorites,
const response = await apiClient.get("/favorites");
return transformFavoritesResponse(response.data);
},
staleTime: 1000 * 60 * 5, staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10, // Keep in cache for 10 minutes
retry: 1, retry: 1,
}); });
} }
// Get favorite product IDs as Set for O(1) lookup - ALWAYS loads favorites first
export function useFavoriteIds() {
const { data: favorites, isLoading } = useFavorites();
// Return Set with IDs, empty Set while loading
return {
favoriteIds: new Set(favorites?.map((fav) => fav.product.id) || []),
isLoading,
};
}
// Check if product is favorited - with loading state
export function useIsFavorite(productId: number) {
const { favoriteIds, isLoading } = useFavoriteIds();
return {
isFavorite: favoriteIds.has(productId),
isLoading,
};
}
// Toggle favorite (add/remove) with optimistic updates
export function useToggleFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
productId,
isFavorite,
}: {
productId: number;
isFavorite: boolean;
}) => {
const formData = new URLSearchParams({
product_id: productId.toString(),
});
await apiClient.post("/favorites", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return { productId, wasAdded: !isFavorite };
},
onMutate: async ({ productId, isFavorite }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ["favorites"] });
// Snapshot previous
const previousFavorites = queryClient.getQueryData<Favorite[]>([
"favorites",
]);
// Optimistically update
queryClient.setQueryData<Favorite[]>(["favorites"], (old = []) => {
if (isFavorite) {
// Remove from favorites
return old.filter((fav) => fav.product.id !== productId);
}
// For add, we'll refetch to get full product data
return old;
});
return { previousFavorites };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousFavorites) {
queryClient.setQueryData(["favorites"], context.previousFavorites);
}
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ["favorites"] });
},
});
}
// Add to favorites
export function useAddToFavorites() { export function useAddToFavorites() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -74,13 +153,13 @@ export function useAddToFavorites() {
product_id: productId.toString(), product_id: productId.toString(),
}); });
const response = await apiClient.post("/favorites", formData, { await apiClient.post("/favorites", formData, {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
}); });
return transformActionResponse(response.data, "Added"); return productId;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["favorites"] }); queryClient.invalidateQueries({ queryKey: ["favorites"] });
@@ -88,6 +167,7 @@ export function useAddToFavorites() {
}); });
} }
// Remove from favorites
export function useRemoveFromFavorites() { export function useRemoveFromFavorites() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -97,13 +177,13 @@ export function useRemoveFromFavorites() {
product_id: productId.toString(), product_id: productId.toString(),
}); });
const response = await apiClient.post("/favorites", formData, { await apiClient.post("/favorites", formData, {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
}); });
return transformActionResponse(response.data, "Removed"); return productId;
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["favorites"] }); queryClient.invalidateQueries({ queryKey: ["favorites"] });

View File

@@ -21,7 +21,7 @@ export default function HeroCarousel({ items }: { items: CarouselItem[] }) {
> >
{items.map((item, i) => ( {items.map((item, i) => (
<SwiperSlide key={i}> <SwiperSlide key={i}>
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[420px]"> <div className="relative w-full h-[200px] sm:h-[300px] md:h-[496px]">
<Image <Image
src={item.image} src={item.image}
alt={item.title} alt={item.title}

View File

@@ -5,8 +5,12 @@ import InfiniteScroll from "react-infinite-scroll-component";
import HeroCarousel from "./Carousel"; import HeroCarousel from "./Carousel";
import CategoryGrid from "./CategoryGrid"; import CategoryGrid from "./CategoryGrid";
import CollectionSection from "./ProductGrid"; import CollectionSection from "./ProductGrid";
import { useCategories, useCarousels, useCollections } from "@/lib/hooks"; import {
useCategories,
useCarousels,
useCollections,
useFavorites,
} from "@/lib/hooks";
export default function HomePage() { export default function HomePage() {
const locale = useLocale(); const locale = useLocale();
@@ -28,6 +32,9 @@ export default function HomePage() {
isError: collectionsError, isError: collectionsError,
} = useCollections(); } = useCollections();
// CRITICAL: Prefetch favorites on mount to avoid loading states
const { isLoading: favoritesLoading } = useFavorites();
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
const loadMore = () => { const loadMore = () => {
@@ -48,8 +55,12 @@ export default function HomePage() {
const visibleCollections = collections?.slice(0, visibleCount) || []; const visibleCollections = collections?.slice(0, visibleCount) || [];
const hasMore = collections ? visibleCount < collections.length : false; const hasMore = collections ? visibleCount < collections.length : false;
// Show loading indicator while favorites are being fetched
const showFavoritesLoading =
favoritesLoading && !categoriesLoading && !collectionsLoading;
return ( return (
<div className="px-2 md:px-4 lg:px-4 pt-4 pb-12 space-y-8 max-w-[1504px] mx-auto"> <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 && ( {!carouselsLoading && carouselItems.length > 0 && (
<HeroCarousel items={carouselItems} /> <HeroCarousel items={carouselItems} />
)} )}
@@ -62,6 +73,13 @@ export default function HomePage() {
title={t("categories")} 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 ? ( {collectionsError ? (
<section className="bg-white rounded-2xl shadow-sm p-6"> <section className="bg-white rounded-2xl shadow-sm p-6">
<p className="text-red-600"> <p className="text-red-600">

View File

@@ -13,6 +13,7 @@ import {
} from "@/components/ui/carousel"; } from "@/components/ui/carousel";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { useToggleFavorite, useIsFavorite } from "@/lib/hooks";
type ProductCardProps = { type ProductCardProps = {
id: number; id: number;
@@ -22,12 +23,10 @@ type ProductCardProps = {
discount?: number | null; discount?: number | null;
discount_text?: string | null; discount_text?: string | null;
images: string[]; images: string[];
is_favorite: boolean;
labels?: { text: string; bg_color: string }[]; labels?: { text: string; bg_color: string }[];
price_color?: string; price_color?: string;
height?: number; height?: number;
width?: number; width?: number;
button?: boolean;
}; };
export default function ProductCard({ export default function ProductCard({
@@ -38,32 +37,29 @@ export default function ProductCard({
discount, discount,
discount_text, discount_text,
images, images,
is_favorite,
labels = [], labels = [],
price_color = "#005bff", price_color = "#005bff",
height = 360, height = 360,
width = 280, width = 280,
button = true,
}: ProductCardProps) { }: ProductCardProps) {
const [favorite, setFavorite] = useState(is_favorite); const { isFavorite, isLoading: isFavoriteLoading } = useIsFavorite(id);
const { mutate: toggleFavorite, isPending } = useToggleFavorite();
const [api, setApi] = useState<CarouselApi>(); const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
const autoplayRef = useRef<NodeJS.Timeout | null>(null); const autoplayRef = useRef<NodeJS.Timeout | null>(null);
const hasMultipleImages = images.length > 1; const hasMultipleImages = images.length > 1;
// Track carousel current slide
useEffect(() => { useEffect(() => {
if (!api) return; if (!api) return;
setCurrent(api.selectedScrollSnap()); setCurrent(api.selectedScrollSnap());
api.on("select", () => { api.on("select", () => {
setCurrent(api.selectedScrollSnap()); setCurrent(api.selectedScrollSnap());
}); });
}, [api]); }, [api]);
// Auto-play functionality - 3 seconds
useEffect(() => { useEffect(() => {
if (!api || !hasMultipleImages) return; if (!api || !hasMultipleImages) return;
@@ -85,28 +81,34 @@ export default function ProductCard({
}; };
startAutoplay(); startAutoplay();
return () => stopAutoplay(); return () => stopAutoplay();
}, [api, hasMultipleImages]); }, [api, hasMultipleImages]);
const handleFavorite = async (e: MouseEvent<HTMLButtonElement>) => { const handleFavorite = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const newFavoriteState = !favorite; toggleFavorite(
setFavorite(newFavoriteState); { productId: id, isFavorite },
{
if (newFavoriteState) { onSuccess: (data) => {
toast.success("Товар добавлен в избранное"); toast.success(
} else { data.wasAdded
toast.success("Товар удален из избранного"); ? "Товар добавлен в избранное"
: "Товар удален из избранного"
);
},
onError: () => {
toast.error("Ошибка. Попробуйте снова");
},
} }
);
}; };
const handleCardClick = (e: MouseEvent<HTMLAnchorElement>) => { const handleCardClick = (e: MouseEvent<HTMLAnchorElement>) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if ( if (
target.closest('button') || target.closest("button") ||
target.closest('[data-carousel-control="true"]') target.closest('[data-carousel-control="true"]')
) { ) {
e.preventDefault(); e.preventDefault();
@@ -122,20 +124,19 @@ export default function ProductCard({
return ( return (
<a <a
href={`/product/${id}`} href={`/product/${id}`}
className="no-underline block" className="no-underline flex justify-center"
onClick={handleCardClick} onClick={handleCardClick}
> >
<Card <Card
className="relative gap-2 border-none shadow-none p-0 w-full overflow-hidden rounded-2xl hover:shadow-md transition-all cursor-pointer" className="relative gap-2 border-none shadow-none p-0 w-full overflow-hidden rounded-2xl cursor-pointer"
style={{ height, maxWidth: width }} style={{ height, maxWidth: width }}
> >
{/* Image Section with Carousel */}
<div className="relative w-full h-[260px] group"> <div className="relative w-full h-[260px] group">
<Carousel <Carousel
opts={{ opts={{
align: "start", align: "start",
loop: true, loop: true,
watchDrag: false, // Disable drag/swipe on desktop watchDrag: false,
}} }}
setApi={setApi} setApi={setApi}
className="w-full h-full" className="w-full h-full"
@@ -143,7 +144,7 @@ export default function ProductCard({
<CarouselContent className="h-[260px] ml-0"> <CarouselContent className="h-[260px] ml-0">
{images.map((image, index) => ( {images.map((image, index) => (
<CarouselItem key={index} className="h-[260px] pl-0"> <CarouselItem key={index} className="h-[260px] pl-0">
<div className="h-full flex items-center justify-center p-2"> <div className="h-full flex items-center justify-center">
<img <img
src={image} src={image}
alt={`${name} - ${index + 1}`} alt={`${name} - ${index + 1}`}
@@ -155,7 +156,6 @@ export default function ProductCard({
))} ))}
</CarouselContent> </CarouselContent>
{/* Navigation Arrows - Only show if multiple images */}
{hasMultipleImages && ( {hasMultipleImages && (
<> <>
<CarouselPrevious <CarouselPrevious
@@ -172,19 +172,21 @@ export default function ProductCard({
)} )}
</Carousel> </Carousel>
{/* Favorite Button */} {/* Favorite button - show skeleton while loading favorites */}
<button <button
onClick={handleFavorite} onClick={handleFavorite}
className="absolute top-3 right-3 z-10 rounded-full bg-white/80 p-2 hover:bg-white transition-all" disabled={isPending || isFavoriteLoading}
className="absolute top-3 right-3 z-10 rounded-full bg-white/80 p-2 hover:bg-white transition-all disabled:opacity-50"
> >
{favorite ? ( {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-red-500 fill-red-500" />
) : ( ) : (
<Heart className="w-5 h-5 text-gray-700" /> <Heart className="w-5 h-5 text-gray-700" />
)} )}
</button> </button>
{/* Image Indicators */}
{hasMultipleImages && ( {hasMultipleImages && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex gap-1.5"> <div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
{images.map((_, index) => ( {images.map((_, index) => (
@@ -200,7 +202,6 @@ export default function ProductCard({
</div> </div>
)} )}
{/* Labels */}
{labels?.length > 0 && ( {labels?.length > 0 && (
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10"> <div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
{labels.map((label, idx) => ( {labels.map((label, idx) => (
@@ -216,15 +217,16 @@ export default function ProductCard({
)} )}
</div> </div>
{/* Content */}
<CardContent className="p-0 space-y-1"> <CardContent className="p-0 space-y-1">
<p <p
className="text-sm font-semibold mx-2" className="text-sm mx-2 font-medium"
style={{ color: price_color }} style={{ color: price_color }}
> >
{struct_price_text} {struct_price_text}
</p> </p>
<p className="text-gray-800 text-sm truncate mx-2">{name}</p> <p className="text-black text-sm font-semibold leading-normal truncate mx-2">
{name}
</p>
</CardContent> </CardContent>
</Card> </Card>
</a> </a>

View File

@@ -60,7 +60,7 @@ export default function CollectionSection({ collection, locale }: Props) {
const displayProducts = productsData?.data.slice(0, 10) || []; const displayProducts = productsData?.data.slice(0, 10) || [];
return ( return (
<section className="bg-white rounded-2xl shadow-sm "> <section className="bg-white rounded-2xl shadow-sm p-6">
<div <div
className="flex items-center justify-between mb-4 cursor-pointer group" className="flex items-center justify-between mb-4 cursor-pointer group"
onClick={handleTitleClick} onClick={handleTitleClick}
@@ -73,14 +73,15 @@ export default function CollectionSection({ collection, locale }: Props) {
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{displayProducts.map((product) => { {displayProducts.map((product) => {
// 🔥 TÜM RESİMLERİ AL - Burada değişiklik! const allImages = product.media
const allImages = product.media?.map( ?.map(
(media) => (media) =>
media.images_800x800 || media.images_800x800 ||
media.images_720x720 || media.images_720x720 ||
media.images_400x400 || media.images_400x400 ||
media.thumbnail media.thumbnail
).filter(Boolean) || ["/placeholder-product.jpg"]; )
.filter(Boolean) || ["/placeholder-product.jpg"];
const formattedPrice = product.price_amount const formattedPrice = product.price_amount
? `${parseFloat(product.price_amount).toFixed(2)} TMT` ? `${parseFloat(product.price_amount).toFixed(2)} TMT`
@@ -95,13 +96,11 @@ export default function CollectionSection({ collection, locale }: Props) {
product.price_amount ? parseFloat(product.price_amount) : null product.price_amount ? parseFloat(product.price_amount) : null
} }
struct_price_text={formattedPrice} struct_price_text={formattedPrice}
images={allImages} // 🔥 Array olarak tüm resimler images={allImages}
is_favorite={false}
labels={[]} labels={[]}
price_color="#111" price_color="#0059ff"
height={360} height={360}
width={250} width={250}
button={false}
/> />
); );
})} })}

7
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"baseline-browser-mapping": "^2.9.5",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.1", "eslint-config-next": "16.0.1",
"tailwindcss": "^4", "tailwindcss": "^4",
@@ -3868,9 +3869,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.21", "version": "2.9.5",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.21.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz",
"integrity": "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q==", "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {

View File

@@ -42,6 +42,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"baseline-browser-mapping": "^2.9.5",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.0.1", "eslint-config-next": "16.0.1",
"tailwindcss": "^4", "tailwindcss": "^4",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

BIN
public/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
public/seller.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB