545 lines
17 KiB
JavaScript
545 lines
17 KiB
JavaScript
import React, { useState, useEffect } from "react";
|
||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||
import styles from "./ProductPage.module.scss";
|
||
import { IoMdHeartEmpty, IoMdHeart } from "react-icons/io";
|
||
import { FaShoppingCart } from "react-icons/fa";
|
||
import ProductCard from "../../components/ProductCard/index";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
useGetProductByIdQuery,
|
||
useGetRelatedProductsQuery,
|
||
} from "../../app/api/categories";
|
||
import ReviewSection from "../../components/Review/index";
|
||
import { Modal } from "antd";
|
||
|
||
import {
|
||
useAddFavoriteMutation,
|
||
useRemoveFavoriteMutation,
|
||
} from "../../app/api/favoritesApi";
|
||
import { useGetFavoritesQuery } from "../../app/api/favoritesApi";
|
||
import { useCart } from "../../app/api/useCart";
|
||
import {
|
||
useAddToCartMutation,
|
||
useUpdateCartItemMutation,
|
||
useRemoveFromCartMutation,
|
||
} from "../../app/api/cartApi";
|
||
import ImageCarousel from "../../components/ProductCard/imageCarousel/index";
|
||
import Loader from "../../components/Loader/index";
|
||
import { Result, Button } from "antd";
|
||
import { div } from "framer-motion/client";
|
||
import PendingPriceBadge from "../../components/PendingPriceBadge";
|
||
|
||
const isPriceZero = (price) => !price || parseFloat(price) === 0;
|
||
|
||
const ProductPage = ({
|
||
productProp,
|
||
showAddToCart = true,
|
||
showFavoriteButton = true,
|
||
onAddToCart,
|
||
onToggleFavorite,
|
||
isFavorite = false,
|
||
}) => {
|
||
const navigate = useNavigate();
|
||
const { productId } = useParams();
|
||
const { t } = useTranslation();
|
||
|
||
const {
|
||
data: productResponse,
|
||
error: productError,
|
||
isLoading: productLoading,
|
||
} = useGetProductByIdQuery(productId);
|
||
|
||
const {
|
||
data: similarProductsResponse,
|
||
error: similarProductsError,
|
||
isLoading: similarProductsLoading,
|
||
} = useGetRelatedProductsQuery(productId);
|
||
|
||
const product = productResponse?.data;
|
||
const similarProducts = similarProductsResponse?.data;
|
||
|
||
const [stockErrorModalVisible, setStockErrorModalVisible] = useState(false);
|
||
const [addFavorite] = useAddFavoriteMutation();
|
||
const [removeFavorite] = useRemoveFavoriteMutation();
|
||
const { data: favoriteProducts = [] } = useGetFavoritesQuery();
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [localIsFavorite, setLocalIsFavorite] = useState(
|
||
favoriteProducts.some((fav) => fav.product?.id === product?.id),
|
||
);
|
||
|
||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||
const [showReadMore, setShowReadMore] = useState(false);
|
||
const descRef = React.useRef(null);
|
||
const productInfoRef = React.useRef(null);
|
||
|
||
useEffect(() => {
|
||
if (!product?.description) return;
|
||
|
||
const plainText = product.description.replace(/<[^>]*>/g, "").trim();
|
||
setShowReadMore(plainText.length > 300);
|
||
}, [product?.description]);
|
||
|
||
const { getCartItem } = useCart();
|
||
|
||
const [addToCart] = useAddToCartMutation();
|
||
const [updateCartItem] = useUpdateCartItemMutation();
|
||
const [removeFromCart] = useRemoveFromCartMutation();
|
||
const [localQuantity, setLocalQuantity] = useState(0);
|
||
const [pendingQuantity, setPendingQuantity] = useState(0);
|
||
|
||
const cartItem = getCartItem(product?.id || productId);
|
||
|
||
// ✅ Sync local state with server cart
|
||
useEffect(() => {
|
||
const qty = parseInt(
|
||
cartItem?.quantity || cartItem?.product_quantity || 0,
|
||
10,
|
||
);
|
||
setLocalQuantity(qty);
|
||
setPendingQuantity(qty);
|
||
}, [cartItem]);
|
||
|
||
// ✅ Sync favorite status
|
||
useEffect(() => {
|
||
if (Array.isArray(favoriteProducts)) {
|
||
const isFav = favoriteProducts.some(
|
||
(fav) => fav.product?.id === product?.id,
|
||
);
|
||
setLocalIsFavorite(isFav);
|
||
}
|
||
}, [favoriteProducts, product?.id]);
|
||
|
||
// ✅ Toggle Favorite
|
||
const handleToggleFavorite = async (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (isLoading) return;
|
||
|
||
setIsLoading(true);
|
||
const originalState = localIsFavorite;
|
||
setLocalIsFavorite(!originalState); // Optimistic Update
|
||
|
||
try {
|
||
if (originalState) {
|
||
await removeFavorite(product.id).unwrap();
|
||
} else {
|
||
await addFavorite(product.id).unwrap();
|
||
}
|
||
} catch (error) {
|
||
console.error("Failed to toggle favorite:", error);
|
||
setLocalIsFavorite(originalState); // Rollback
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
// ✅ Add to Cart (Initial)
|
||
const handleAddToCart = async (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
if (product.stock <= 0) {
|
||
setStockErrorModalVisible(true);
|
||
return;
|
||
}
|
||
|
||
setLocalQuantity(1);
|
||
setPendingQuantity(1);
|
||
|
||
try {
|
||
await addToCart({ productId: product.id, quantity: 1 }).unwrap();
|
||
} catch (error) {
|
||
console.error("Failed to add to cart:", error);
|
||
setLocalQuantity(0);
|
||
setPendingQuantity(0);
|
||
}
|
||
};
|
||
|
||
const handleQuantityIncrease = (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (isLoading) return;
|
||
|
||
if (localQuantity >= product.stock) {
|
||
setStockErrorModalVisible(true);
|
||
return;
|
||
}
|
||
|
||
setLocalQuantity((prev) => prev + 1);
|
||
setPendingQuantity((prev) => prev + 1);
|
||
};
|
||
|
||
const handleQuantityDecrease = (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
if (isLoading) return;
|
||
|
||
if (pendingQuantity <= 1) {
|
||
setPendingQuantity(0);
|
||
setLocalQuantity(0);
|
||
setIsLoading(true);
|
||
removeFromCart({ productId: product.id })
|
||
.unwrap()
|
||
.then(() => {
|
||
// Success handled by hook
|
||
})
|
||
.catch(() => {
|
||
setLocalQuantity(1);
|
||
setPendingQuantity(1);
|
||
})
|
||
.finally(() => {
|
||
setIsLoading(false);
|
||
});
|
||
} else {
|
||
setLocalQuantity((prev) => prev - 1);
|
||
setPendingQuantity((prev) => prev - 1);
|
||
}
|
||
};
|
||
|
||
// ✅ Debounced Cart Update
|
||
useEffect(() => {
|
||
const serverQty = parseInt(
|
||
cartItem?.quantity || cartItem?.product_quantity || 0,
|
||
10,
|
||
);
|
||
|
||
if (pendingQuantity === serverQty || pendingQuantity <= 0) {
|
||
return;
|
||
}
|
||
|
||
const handler = setTimeout(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
await updateCartItem({
|
||
productId: product.id,
|
||
quantity: pendingQuantity,
|
||
}).unwrap();
|
||
} catch (error) {
|
||
console.error("Failed to update cart item:", error);
|
||
setLocalQuantity(serverQty);
|
||
setPendingQuantity(serverQty);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, 500);
|
||
|
||
return () => clearTimeout(handler);
|
||
}, [pendingQuantity, cartItem, product?.id, updateCartItem]);
|
||
|
||
if (productLoading || similarProductsLoading) return <Loader />;
|
||
if (productError || similarProductsError)
|
||
return (
|
||
<Result
|
||
status="500"
|
||
title="500"
|
||
subTitle="Näbelli ýalňyşlyk ýüze çykdy."
|
||
extra={
|
||
<Button type="primary" onClick={() => navigate("/")}>
|
||
Baş sahypa gidiň
|
||
</Button>
|
||
}
|
||
/>
|
||
);
|
||
|
||
if (!product) return <div>Can not find product</div>;
|
||
|
||
const categoryName = product.categories?.[0]?.name || "Category";
|
||
const categoryId = product.categories?.[0]?.id;
|
||
|
||
const handleCategoryClick = (categoryId) => {
|
||
navigate(`/category/${categoryId}`);
|
||
};
|
||
|
||
// ── Cart + favorite butonları (desktop purchase card + mobile bar'da ortak) ──
|
||
const CartButtons = () => (
|
||
<div className={styles.Btn}>
|
||
{showFavoriteButton && (
|
||
<button
|
||
className={styles.favoriteButton}
|
||
onClick={handleToggleFavorite}
|
||
>
|
||
{localIsFavorite ? <IoMdHeart /> : <IoMdHeartEmpty />}
|
||
</button>
|
||
)}
|
||
|
||
{showAddToCart && (
|
||
<>
|
||
{localQuantity > 0 ? (
|
||
<div className={styles.quantityControls}>
|
||
<button
|
||
onClick={handleQuantityDecrease}
|
||
className={styles.quantityBtn}
|
||
>
|
||
<svg
|
||
viewBox="0 0 9 11"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M1.41422 6.86246C0.633166 6.08141 0.633165 4.81508 1.41421 4.03403L4.61487 0.833374C5.8748 -0.426555 8.02908 0.465776 8.02908 2.24759V8.6489C8.02908 10.4307 5.8748 11.323 4.61487 10.0631L1.41422 6.86246Z"
|
||
fill="white"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<span>{localQuantity}</span>
|
||
<button
|
||
onClick={handleQuantityIncrease}
|
||
className={styles.quantityBtn}
|
||
>
|
||
<svg
|
||
viewBox="0 0 9 11"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
>
|
||
<path
|
||
d="M6.64389 4.03427C7.42494 4.81532 7.42494 6.08165 6.64389 6.8627L3.44324 10.0634C2.18331 11.3233 0.0290222 10.431 0.0290226 8.64914V2.24783C0.0290226 0.466021 2.18331 -0.426312 3.44324 0.833617L6.64389 4.03427Z"
|
||
fill="white"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
className={styles.addToCartButton}
|
||
onClick={handleAddToCart}
|
||
>
|
||
<FaShoppingCart />
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
{/* Breadcrumb */}
|
||
<div className={styles.breadcrumb}>
|
||
<span onClick={() => handleCategoryClick(categoryId)}>
|
||
{categoryName}
|
||
</span>
|
||
<span className={styles.separator}>/</span>
|
||
<span>{product?.name || "Product"}</span>
|
||
</div>
|
||
|
||
{/* ── 3 kolon ana section ── */}
|
||
<div className={styles.productSection}>
|
||
{/* KOLON 1: Resim */}
|
||
<div className={styles.productImage}>
|
||
<ImageCarousel
|
||
images={product.media}
|
||
altText={product.name}
|
||
showThumbnails={true}
|
||
isDetailView={true}
|
||
/>
|
||
</div>
|
||
|
||
{/* KOLON 2: İsim + Meta + Description */}
|
||
<div className={styles.productInfo} ref={productInfoRef}>
|
||
{/* Meta tablo */}
|
||
<div className={styles.productMeta}>
|
||
<h1 className={styles.productTitle}>{product.name}</h1>
|
||
|
||
{/* <div className={styles.metaItem}>
|
||
<span className={styles.metaLabel}>
|
||
{t("product.productCode")}
|
||
</span>
|
||
<span className={styles.metaValue}>{product.id}</span>
|
||
</div>
|
||
|
||
{product.barcode && (
|
||
<div className={styles.metaItem}>
|
||
<span className={styles.metaLabel}>{t("product.barCode")}</span>
|
||
<span className={styles.metaValue}>{product.barcode}</span>
|
||
</div>
|
||
)} */}
|
||
|
||
{product.brand?.name && (
|
||
<a
|
||
href={`/brands/${product.brand.id}`}
|
||
target="_blank"
|
||
className={styles.metaItem}
|
||
>
|
||
<span className={styles.metaLabel}>{t("order.brand")}</span>
|
||
<span className={styles.metaValue}>{product.brand.name}</span>
|
||
</a>
|
||
)}
|
||
|
||
{product.channel?.[0]?.name && (
|
||
|
||
<Link to={`/channel/${product.channel[0].id}`} state={{ clearFilters: true }} className={styles.metaItem}>
|
||
<span className={styles.metaLabel}>{t("order.channel")}</span>
|
||
<span className={styles.metaValue}>
|
||
{product.channel[0].name}
|
||
</span>
|
||
</Link>
|
||
|
||
)}
|
||
|
||
{product.properties?.length > 0 && (
|
||
product.properties.map((prop, index) => (
|
||
<div key={`${prop.attribute_id}-${index}`} className={styles.metaItem}>
|
||
<span className={styles.metaLabel}>{prop.name}</span>
|
||
<span className={styles.metaValue}>{prop.value}</span>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* Description card */}
|
||
{product.description && (
|
||
<div className={`${styles.descriptionCard} ${!isDescExpanded && showReadMore ? styles.descriptionCardCollapsed : ''}`}>
|
||
<div className={styles.descriptionHeader}>
|
||
<div className={styles.descriptionIcon}>
|
||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<p className={styles.descriptionTitle}>
|
||
{t("product.description")}
|
||
</p>
|
||
</div>
|
||
<div className={styles.productDescriptionWrapper}>
|
||
<div
|
||
ref={descRef}
|
||
className={`${styles.productDescription} ${
|
||
!isDescExpanded && showReadMore ? styles.productDescriptionCollapsed : ""
|
||
}`}
|
||
dangerouslySetInnerHTML={{ __html: product.description }}
|
||
/>
|
||
{showReadMore && !isDescExpanded && (
|
||
<button
|
||
className={styles.readMoreBtn}
|
||
onClick={() => setIsDescExpanded(true)}
|
||
>
|
||
{t("product.readMore")}
|
||
</button>
|
||
)}
|
||
{showReadMore && isDescExpanded && (
|
||
<button
|
||
className={styles.readMoreBtn}
|
||
onClick={() => setIsDescExpanded(false)}
|
||
>
|
||
{t("product.readLess")}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* KOLON 3: Satın alma kartı (sadece desktop/tablet) */}
|
||
<div className={styles.purchaseCol}>
|
||
<div className={styles.purchaseCard}>
|
||
{/* Fiyat */}
|
||
<div className={styles.priceRow}>
|
||
<span className={styles.priceLabel}>{t("product.price")}:</span>
|
||
<div className={styles.priceRight}>
|
||
{isPriceZero(product.price_amount) ? (
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontWeight: 600 }}>
|
||
{t("cart.pendingPriceTitle")} <PendingPriceBadge />
|
||
</span>
|
||
) : (
|
||
<>
|
||
<span className={styles.price}>{product.price_amount} m.</span>
|
||
{product.old_price_amount && (
|
||
<span className={styles.oldPrice}>
|
||
{product.old_price_amount} m.
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Butonlar */}
|
||
<CartButtons />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Mobile sticky bar ── */}
|
||
<div className={styles.productActionsMobile}>
|
||
<div className={styles.mobilePriceContainer}>
|
||
{isPriceZero(product.price_amount) ? (
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, fontWeight: 600 }}>
|
||
{t("cart.pendingPriceTitle")} <PendingPriceBadge />
|
||
</span>
|
||
) : (
|
||
<>
|
||
<span className={styles.price}>{product.price_amount} m.</span>
|
||
{product.old_price_amount && (
|
||
<span className={styles.oldPrice}>
|
||
{product.old_price_amount} m.
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className={styles.mobileBtnContainer}>
|
||
<CartButtons />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Reviews */}
|
||
<ReviewSection
|
||
productId={productId}
|
||
existingReviews={product.reviews_resources}
|
||
reviewStats={product.reviews}
|
||
/>
|
||
|
||
{/* Similar Products */}
|
||
<div className={styles.similarProducts}>
|
||
<h2 className={styles.sectionTitle}>{t("product.similarProducts")}</h2>
|
||
<div className={styles.productsGrid}>
|
||
{Array.isArray(similarProducts) &&
|
||
similarProducts.map((product) => (
|
||
<ProductCard
|
||
key={product.id}
|
||
product={product}
|
||
onAddToCart={handleAddToCart}
|
||
onToggleFavorite={handleToggleFavorite}
|
||
showAddToCart={true}
|
||
showFavoriteButton={true}
|
||
isFavorite={false}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stock modal */}
|
||
<Modal
|
||
title={t("common.warning")}
|
||
open={stockErrorModalVisible}
|
||
onOk={() => setStockErrorModalVisible(false)}
|
||
onCancel={() => setStockErrorModalVisible(false)}
|
||
okText={t("common.ok")}
|
||
footer={[
|
||
<button
|
||
key="ok"
|
||
onClick={() => setStockErrorModalVisible(false)}
|
||
className={styles.modalButton}
|
||
>
|
||
{t("common.ok")}
|
||
</button>,
|
||
]}
|
||
>
|
||
<p>
|
||
{t("common.not_enough_stock", {
|
||
available: product.stock,
|
||
requested: localQuantity + 1,
|
||
})}
|
||
</p>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ProductPage;
|
||
|