Files
mm.com.tm-frontend/src/pages/ProductDetail/index.jsx
2026-04-30 16:15:29 +05:00

545 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;