fixed favorites api
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -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
|
||||||
|
|||||||
@@ -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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
80
components/PageLoader/PreLoader.tsx
Normal file
80
components/PageLoader/PreLoader.tsx
Normal 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. Something’s wrong.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Preloader;
|
||||||
66
components/icons.tsx
Normal file
66
components/icons.tsx
Normal 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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"] });
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
7
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
BIN
public/logo.png
BIN
public/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
BIN
public/logo.webp
Normal file
BIN
public/logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/seller.png
Normal file
BIN
public/seller.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Reference in New Issue
Block a user