413 lines
No EOL
12 KiB
JavaScript
413 lines
No EOL
12 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Grid,
|
|
Card,
|
|
CardMedia,
|
|
Button,
|
|
Chip,
|
|
Divider,
|
|
TextField,
|
|
IconButton,
|
|
CircularProgress,
|
|
Paper,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableRow,
|
|
Breadcrumbs,
|
|
Link,
|
|
useTheme
|
|
} from '@mui/material';
|
|
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
|
|
import AddIcon from '@mui/icons-material/Add';
|
|
import RemoveIcon from '@mui/icons-material/Remove';
|
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
|
import { Rating } from '@mui/material';
|
|
import ProductReviews from '@components/ProductReviews';
|
|
import { Link as RouterLink } from 'react-router-dom';
|
|
import { useProduct, useAddToCart } from '@hooks/apiHooks';
|
|
import { useAuth } from '@hooks/reduxHooks';
|
|
import imageUtils from '@utils/imageUtils';
|
|
import SEOMetaTags from '@components/SEOMetaTags';
|
|
import useProductSeo from '@hooks/useProductSeo';
|
|
|
|
const ProductDetailPage = () => {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const theme = useTheme();
|
|
const { isAuthenticated, user } = useAuth();
|
|
const [quantity, setQuantity] = useState(1);
|
|
const [selectedImage, setSelectedImage] = useState(0);
|
|
|
|
// Fetch product data
|
|
const { data: products, isLoading, error } = useProduct(id);
|
|
const addToCart = useAddToCart();
|
|
let product = products?.length > 0 ? products[0] : null
|
|
|
|
// Get SEO metadata for the product
|
|
const {
|
|
title,
|
|
description,
|
|
keywords,
|
|
image,
|
|
canonical,
|
|
structuredData
|
|
} = useProductSeo(product);
|
|
|
|
// Handle quantity changes
|
|
const increaseQuantity = () => {
|
|
if (product && quantity < product.stock_quantity) {
|
|
setQuantity(quantity + 1);
|
|
}
|
|
};
|
|
|
|
const decreaseQuantity = () => {
|
|
if (quantity > 1) {
|
|
setQuantity(quantity - 1);
|
|
}
|
|
};
|
|
|
|
const handleQuantityChange = (e) => {
|
|
const value = parseInt(e.target.value, 10);
|
|
if (isNaN(value) || value < 1) {
|
|
setQuantity(1);
|
|
} else if (product && value > product.stock_quantity) {
|
|
setQuantity(product.stock_quantity);
|
|
} else {
|
|
setQuantity(value);
|
|
}
|
|
};
|
|
|
|
// Handle add to cart
|
|
const handleAddToCart = () => {
|
|
if (!isAuthenticated) {
|
|
navigate('/auth/login', { state: { from: `/products/${id}` } });
|
|
return;
|
|
}
|
|
|
|
addToCart.mutate({
|
|
userId: user,
|
|
productId: id,
|
|
quantity
|
|
});
|
|
};
|
|
|
|
// Format properties for display
|
|
const getProductProperties = () => {
|
|
if (!product) return [];
|
|
|
|
const properties = [];
|
|
|
|
if (product.category_name) {
|
|
properties.push({ name: 'Category', value: product.category_name });
|
|
}
|
|
|
|
if (product.material_type) {
|
|
properties.push({ name: 'Material', value: product.material_type });
|
|
}
|
|
|
|
if (product.color) {
|
|
properties.push({ name: 'Color', value: product.color });
|
|
}
|
|
|
|
if (product.origin) {
|
|
properties.push({ name: 'Origin', value: product.origin });
|
|
}
|
|
|
|
if (product.weight_grams) {
|
|
properties.push({ name: 'Weight', value: `${product.weight_grams}g` });
|
|
}
|
|
|
|
if (product.length_cm) {
|
|
properties.push({ name: 'Length', value: `${product.length_cm}cm` });
|
|
}
|
|
|
|
if (product.width_cm) {
|
|
properties.push({ name: 'Width', value: `${product.width_cm}cm` });
|
|
}
|
|
|
|
if (product.height_cm) {
|
|
properties.push({ name: 'Height', value: `${product.height_cm}cm` });
|
|
}
|
|
|
|
if (product.age) {
|
|
properties.push({ name: 'Age', value: product.age });
|
|
}
|
|
|
|
return properties;
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', my: 8 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (error || !product) {
|
|
return (
|
|
<Box sx={{ my: 4 }}>
|
|
<Typography variant="h5" color="error" gutterBottom>
|
|
Error loading product details
|
|
</Typography>
|
|
<Button
|
|
variant="contained"
|
|
component={RouterLink}
|
|
to="/products"
|
|
>
|
|
Back to Products
|
|
</Button>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* SEO Meta Tags */}
|
|
<SEOMetaTags
|
|
title={title}
|
|
description={description}
|
|
keywords={keywords}
|
|
image={image}
|
|
canonical={canonical}
|
|
type="product"
|
|
structuredData={structuredData}
|
|
/>
|
|
|
|
<Box>
|
|
{/* Breadcrumbs navigation */}
|
|
<Breadcrumbs
|
|
separator={<NavigateNextIcon fontSize="small" />}
|
|
aria-label="breadcrumb"
|
|
sx={{ mb: 3 }}
|
|
>
|
|
<Link component={RouterLink} to="/" color="inherit">
|
|
Home
|
|
</Link>
|
|
<Link component={RouterLink} to="/products" color="inherit">
|
|
Products
|
|
</Link>
|
|
<Link
|
|
component={RouterLink}
|
|
to={`/products?category=${product.category_name}`}
|
|
color="inherit"
|
|
>
|
|
{product.category_name}
|
|
</Link>
|
|
<Typography color="text.primary">{product.name}</Typography>
|
|
</Breadcrumbs>
|
|
|
|
<Grid container spacing={4}>
|
|
{/* Product Images */}
|
|
<Grid item xs={12} md={6}>
|
|
<Box sx={{ mb: 2 }}>
|
|
<Card>
|
|
<CardMedia
|
|
component="img"
|
|
image={product.images && product.images.length > 0
|
|
?
|
|
imageUtils.getImageUrl(product.images[selectedImage]?.path) : "https://placehold.co/600x400/000000/FFFF"
|
|
}
|
|
alt={product.name}
|
|
sx={{
|
|
height: 400,
|
|
objectFit: 'contain',
|
|
bgcolor: 'background.paper'
|
|
}}
|
|
/>
|
|
</Card>
|
|
</Box>
|
|
|
|
{/* Thumbnail images */}
|
|
{product.images && product.images.length > 1 && (
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
|
|
{product.images.map((image, index) => (
|
|
<Box
|
|
key={image.id}
|
|
onClick={() => setSelectedImage(index)}
|
|
sx={{
|
|
width: 80,
|
|
height: 80,
|
|
cursor: 'pointer',
|
|
border: index === selectedImage ? `2px solid ${theme.palette.primary.main}` : '2px solid transparent',
|
|
borderRadius: 1,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<img
|
|
src={imageUtils.getImageUrl(image.path)}
|
|
alt={`${product.name} thumbnail ${index + 1}`}
|
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
|
/>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Grid>
|
|
|
|
{/* Product Details */}
|
|
<Grid item xs={12} md={6}>
|
|
<Typography variant="h4" component="h1" gutterBottom>
|
|
{product.name}
|
|
</Typography>
|
|
|
|
{product.average_rating && (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', mt: 1, mb: 2 }}>
|
|
<Rating
|
|
value={product.average_rating}
|
|
readOnly
|
|
precision={0.5}
|
|
/>
|
|
<Typography variant="body1" color="text.secondary" sx={{ ml: 1 }}>
|
|
{product.average_rating} ({product.review_count} {product.review_count === 1 ? 'review' : 'reviews'})
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{!product.average_rating && (
|
|
<Box sx={{ mt: 1, mb: 2 }}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
No reviews yet
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
<Typography variant="h5" color="primary" gutterBottom>
|
|
${parseFloat(product.price).toFixed(2)}
|
|
</Typography>
|
|
|
|
{/* Tags */}
|
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, my: 2 }}>
|
|
{product.tags && product.tags.map((tag, index) => (
|
|
<Chip
|
|
key={index}
|
|
label={tag}
|
|
component={RouterLink}
|
|
to={`/products?tag=${tag}`}
|
|
clickable
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
<Typography variant="body1" paragraph sx={{ mt: 2 }}>
|
|
{product.description}
|
|
</Typography>
|
|
|
|
<Divider sx={{ my: 3 }} />
|
|
|
|
{/* Stock information */}
|
|
<Box sx={{ mb: 3 }}>
|
|
<Typography variant="subtitle1" gutterBottom>
|
|
Availability:
|
|
<Chip
|
|
label={product.stock_quantity > 0 ? 'In Stock' : 'Out of Stock'}
|
|
color={product.stock_quantity > 0 ? 'success' : 'error'}
|
|
size="small"
|
|
sx={{ ml: 1 }}
|
|
/>
|
|
</Typography>
|
|
|
|
{product.stock_quantity > 0 && (
|
|
<Typography variant="body2" color="text.secondary">
|
|
{product.stock_quantity} items available
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Add to cart section */}
|
|
{product.stock_quantity > 0 ? (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', mr: 2 }}>
|
|
<IconButton
|
|
onClick={decreaseQuantity}
|
|
disabled={quantity <= 1}
|
|
size="small"
|
|
>
|
|
<RemoveIcon />
|
|
</IconButton>
|
|
|
|
<TextField
|
|
value={quantity}
|
|
onChange={handleQuantityChange}
|
|
inputProps={{ min: 1, max: product.stock_quantity }}
|
|
sx={{ width: 60, mx: 1 }}
|
|
size="small"
|
|
/>
|
|
|
|
<IconButton
|
|
onClick={increaseQuantity}
|
|
disabled={quantity >= product.stock_quantity}
|
|
size="small"
|
|
>
|
|
<AddIcon />
|
|
</IconButton>
|
|
</Box>
|
|
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<ShoppingCartIcon />}
|
|
onClick={handleAddToCart}
|
|
disabled={addToCart.isLoading}
|
|
sx={{ flexGrow: 1 }}
|
|
>
|
|
{addToCart.isLoading ? 'Adding...' : 'Add to Cart'}
|
|
</Button>
|
|
</Box>
|
|
) : (
|
|
<Button
|
|
variant="outlined"
|
|
color="error"
|
|
disabled
|
|
fullWidth
|
|
sx={{ mb: 3 }}
|
|
>
|
|
Out of Stock
|
|
</Button>
|
|
)}
|
|
|
|
{/* Product properties table */}
|
|
<Typography variant="h6" gutterBottom>
|
|
Product Details
|
|
</Typography>
|
|
|
|
<TableContainer component={Paper} variant="outlined" sx={{ mb: 3 }}>
|
|
<Table aria-label="product specifications">
|
|
<TableBody>
|
|
{getProductProperties().map((prop) => (
|
|
<TableRow key={prop.name}>
|
|
<TableCell
|
|
component="th"
|
|
scope="row"
|
|
sx={{
|
|
width: '40%',
|
|
bgcolor: 'background.paper',
|
|
fontWeight: 'medium'
|
|
}}
|
|
>
|
|
{prop.name}
|
|
</TableCell>
|
|
<TableCell>{prop.value}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</Grid>
|
|
|
|
{/* Reviews Section */}
|
|
<Grid item xs={12}>
|
|
<Divider sx={{ my: 4 }} />
|
|
<ProductReviews productId={id} />
|
|
</Grid>
|
|
</Grid>
|
|
</Box>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ProductDetailPage; |