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();
const { mutate: addToCart, isPending: isAddingToCart } = useAddToCart();
const handleRemoveFromFavorites = useCallback((productId: number) => {
const handleRemoveFromFavorites = useCallback(
(productId: number) => {
removeFromFavorites(productId, {
onSuccess: () => {
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(
{ 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">
<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">
@@ -72,7 +79,9 @@ export default function FavoritesPage() {
))}
</div>
</div>
), [t]);
),
[t]
);
if (isLoading) {
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">
{favorites.map((favorite: Favorite) => (
<ProductCard
key={favorite.created_at}
key={favorite.product.id}
productId={favorite.product.id}
product={favorite.product}
onRemove={() => handleRemoveFromFavorites(favorite.product.id)}
@@ -170,7 +179,6 @@ function ProductCard({
>
<Link href={`/product/${productId || product.slug}`} className="block">
<div className="relative aspect-square bg-gray-50">
{/* Favorite Button */}
<button
onClick={(e) => {
e.preventDefault();
@@ -183,7 +191,6 @@ function ProductCard({
<Heart className="h-5 w-5 fill-red-500 text-red-500" />
</button>
{/* Product Image */}
<Image
src={imageUrl}
alt={product.name}
@@ -193,7 +200,6 @@ function ProductCard({
priority={false}
/>
{/* Out of Stock Badge */}
{product.stock === 0 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Badge variant="secondary" className="text-sm">
@@ -203,7 +209,6 @@ function ProductCard({
)}
</div>
{/* Product Info */}
<div className="p-3">
<h3 className="font-medium text-sm line-clamp-2 mb-2 min-h-[40px]">
{product.name}
@@ -217,7 +222,6 @@ function ProductCard({
</div>
</Link>
{/* Add to Cart Button - показывается при hover */}
{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">
<Button

View File

@@ -45,7 +45,7 @@
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--background: #eff3f6;
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
@@ -120,3 +120,73 @@
@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,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import Logo from "@/public/logo.png";
import Logo from "@/public/logo.webp";
import CategoryMenu from "./ui/CategoryMenu";
import SearchBar from "./ui/SearchBar";
import AuthDialog from "./ui/AuthDialog";
@@ -19,6 +19,7 @@ import ActionButtons from "./ui/ActionButtons";
import LanguageSelector from "./ui/LanguageSelector";
import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
import { CategoryIcon } from "../icons";
interface HeaderProps {
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"
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")}
</Button>

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import {
import { useRouter } from "next/navigation";
import { useSearchProducts } from "@/features/search/hooks/useSearch";
import Image from "next/image";
import { SearchIcon } from "@/components/icons";
interface SearchBarProps {
isMobile: boolean;
@@ -158,7 +159,7 @@ export default function SearchBar({
size="icon"
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>
<SearchResults />
</div>

View File

@@ -4,6 +4,7 @@ import { useEffect, type ReactNode } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useAuthStatus, useGetGuestToken } from "@/lib/hooks/useAuth";
import { useUserProfile } from "@/features/profile/hooks/useUserProfile";
import Preloader from "@/components/PageLoader/PreLoader";
interface AuthWrapperProps {
children: ReactNode;
@@ -58,12 +59,7 @@ export default function AuthWrapper({
if (isLoading || (requireAuth && !isAuthenticated)) {
return (
<div className="flex items-center justify-center min-h-screen">
<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>
<Preloader/>
);
}

View File

@@ -2,69 +2,148 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { Favorite } from "@/lib/types/api";
// Response tiplerini tanımlayalım
interface FavoritesResponse {
data?: Favorite[];
[key: string]: any;
}
interface FavoriteActionResponse {
data?: string | Favorite[];
[key: string]: any;
}
// Response'u transform eden yardımcı fonksiyon
function transformFavoritesResponse(response: any): Favorite[] {
if (typeof response === "object" && response.data) {
return response.data;
}
if (typeof response === "string") {
try {
const parsed = JSON.parse(response);
return parsed.data || [];
} catch (error) {
console.error("Failed to parse favorites response:", error);
} catch {
return [];
}
}
return [];
}
function transformActionResponse(response: any, defaultValue: string): string {
if (typeof response === "object" && response.data) {
return response.data;
}
// Fetch ALL favorite products (handle pagination on backend)
async function fetchAllFavorites(): Promise<Favorite[]> {
const allFavorites: Favorite[] = [];
let currentPage = 1;
let hasMorePages = true;
if (typeof response === "string") {
while (hasMorePages) {
try {
const parsed = JSON.parse(response);
return parsed.data || defaultValue;
const response = await apiClient.get("/favorites", {
params: { page: currentPage, perPage: 100 },
});
const favorites = transformFavoritesResponse(response.data);
allFavorites.push(...favorites);
// Check pagination
const pagination = response.data?.pagination;
if (pagination?.next_page_url) {
currentPage++;
} else {
hasMorePages = false;
}
} catch (error) {
if (response.includes("<!doctype html>")) {
return defaultValue;
}
console.error(`Failed to parse favorite response:`, 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() {
return useQuery({
queryKey: ["favorites"],
queryFn: async () => {
const response = await apiClient.get("/favorites");
return transformFavoritesResponse(response.data);
},
queryFn: fetchAllFavorites,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10, // Keep in cache for 10 minutes
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() {
const queryClient = useQueryClient();
@@ -74,13 +153,13 @@ export function useAddToFavorites() {
product_id: productId.toString(),
});
const response = await apiClient.post("/favorites", formData, {
await apiClient.post("/favorites", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return transformActionResponse(response.data, "Added");
return productId;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["favorites"] });
@@ -88,6 +167,7 @@ export function useAddToFavorites() {
});
}
// Remove from favorites
export function useRemoveFromFavorites() {
const queryClient = useQueryClient();
@@ -97,13 +177,13 @@ export function useRemoveFromFavorites() {
product_id: productId.toString(),
});
const response = await apiClient.post("/favorites", formData, {
await apiClient.post("/favorites", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return transformActionResponse(response.data, "Removed");
return productId;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["favorites"] });

View File

@@ -21,7 +21,7 @@ export default function HeroCarousel({ items }: { items: CarouselItem[] }) {
>
{items.map((item, 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
src={item.image}
alt={item.title}

View File

@@ -5,8 +5,12 @@ import InfiniteScroll from "react-infinite-scroll-component";
import HeroCarousel from "./Carousel";
import CategoryGrid from "./CategoryGrid";
import CollectionSection from "./ProductGrid";
import { useCategories, useCarousels, useCollections } from "@/lib/hooks";
import {
useCategories,
useCarousels,
useCollections,
useFavorites,
} from "@/lib/hooks";
export default function HomePage() {
const locale = useLocale();
@@ -28,6 +32,9 @@ export default function HomePage() {
isError: collectionsError,
} = useCollections();
// CRITICAL: Prefetch favorites on mount to avoid loading states
const { isLoading: favoritesLoading } = useFavorites();
useEffect(() => setMounted(true), []);
const loadMore = () => {
@@ -48,8 +55,12 @@ export default function HomePage() {
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-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 && (
<HeroCarousel items={carouselItems} />
)}
@@ -62,6 +73,13 @@ 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">

View File

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

View File

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

7
package-lock.json generated
View File

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

View File

@@ -42,6 +42,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"baseline-browser-mapping": "^2.9.5",
"eslint": "^9",
"eslint-config-next": "16.0.1",
"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