Spaces:
Running
Running
import { useState } from "react"; | |
import { useParams, useLocation } from "wouter"; | |
import { useQuery } from "@tanstack/react-query"; | |
import { useAuth } from "@/hooks/use-auth"; | |
import { useCart } from "@/hooks/use-cart"; | |
import { productsApi } from "@/lib/api"; | |
import Header from "@/components/layout/header"; | |
import CartSidebar from "@/components/cart/cart-sidebar"; | |
import { Button } from "@/components/ui/button"; | |
import { Badge } from "@/components/ui/badge"; | |
import { Separator } from "@/components/ui/separator"; | |
import { Skeleton } from "@/components/ui/skeleton"; | |
import { Card, CardContent } from "@/components/ui/card"; | |
import { Star, Store, ShoppingCart, Plus, Minus, ArrowLeft } from "lucide-react"; | |
import { useToast } from "@/hooks/use-toast"; | |
export default function ProductDetail() { | |
const { id } = useParams(); | |
const [, setLocation] = useLocation(); | |
const { user } = useAuth(); | |
const { addItem } = useCart(); | |
const { toast } = useToast(); | |
const [selectedImageIndex, setSelectedImageIndex] = useState(0); | |
const [quantity, setQuantity] = useState(1); | |
const [searchQuery, setSearchQuery] = useState(""); | |
const { data: product, isLoading, error } = useQuery({ | |
queryKey: ['/api/products', id], | |
queryFn: () => productsApi.getById(id!), | |
enabled: !!id, | |
}); | |
if (isLoading) { | |
return ( | |
<div className="min-h-screen bg-gray-50"> | |
<Header searchQuery={searchQuery} setSearchQuery={setSearchQuery} /> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | |
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> | |
<div> | |
<Skeleton className="aspect-square w-full mb-4" /> | |
<div className="flex space-x-2"> | |
{[...Array(4)].map((_, i) => ( | |
<Skeleton key={i} className="w-16 h-16" /> | |
))} | |
</div> | |
</div> | |
<div className="space-y-4"> | |
<Skeleton className="h-8 w-3/4" /> | |
<Skeleton className="h-4 w-1/2" /> | |
<Skeleton className="h-6 w-1/4" /> | |
<Skeleton className="h-32 w-full" /> | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
if (error || !product) { | |
return ( | |
<div className="min-h-screen bg-gray-50"> | |
<Header searchQuery={searchQuery} setSearchQuery={setSearchQuery} /> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> | |
<div className="text-center"> | |
<h1 className="text-2xl font-bold text-gray-900 mb-4">Product Not Found</h1> | |
<Button onClick={() => setLocation('/')}> | |
<ArrowLeft className="mr-2 h-4 w-4" /> | |
Back to Home | |
</Button> | |
</div> | |
</div> | |
</div> | |
); | |
} | |
const handleAddToCart = async () => { | |
if (!user) { | |
toast({ | |
variant: "destructive", | |
title: "Sign in required", | |
description: "Please sign in to add items to your cart.", | |
}); | |
setLocation('/auth'); | |
return; | |
} | |
try { | |
await addItem(product.id, quantity); | |
toast({ | |
title: "Added to cart", | |
description: `${quantity} ${quantity === 1 ? 'item' : 'items'} added to your cart.`, | |
}); | |
} catch (error) { | |
toast({ | |
variant: "destructive", | |
title: "Error", | |
description: "Failed to add item to cart.", | |
}); | |
} | |
}; | |
const handleBuyNow = async () => { | |
if (!user) { | |
toast({ | |
variant: "destructive", | |
title: "Sign in required", | |
description: "Please sign in to make a purchase.", | |
}); | |
setLocation('/auth'); | |
return; | |
} | |
await handleAddToCart(); | |
setLocation('/checkout'); | |
}; | |
const discountPercentage = product.originalPrice | |
? Math.round((1 - parseFloat(product.price) / parseFloat(product.originalPrice)) * 100) | |
: 0; | |
const images = product.images && product.images.length > 0 ? product.images : ['/api/placeholder/400/400']; | |
return ( | |
<div className="min-h-screen bg-gray-50"> | |
<Header searchQuery={searchQuery} setSearchQuery={setSearchQuery} /> | |
<CartSidebar /> | |
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8" data-testid="product-detail"> | |
<Button | |
variant="ghost" | |
onClick={() => setLocation('/')} | |
className="mb-6" | |
data-testid="back-button" | |
> | |
<ArrowLeft className="mr-2 h-4 w-4" /> | |
Back to Products | |
</Button> | |
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> | |
{/* Product Images */} | |
<div> | |
{/* Main Image */} | |
<div className="aspect-square bg-gray-100 rounded-lg mb-4 overflow-hidden"> | |
<img | |
src={images[selectedImageIndex]} | |
alt={product.title} | |
className="w-full h-full object-cover" | |
data-testid="main-product-image" | |
/> | |
</div> | |
{/* Thumbnail Images */} | |
<div className="flex space-x-2" data-testid="thumbnail-images"> | |
{images.map((image: string, index: number) => ( | |
<button | |
key={index} | |
onClick={() => setSelectedImageIndex(index)} | |
className={`w-16 h-16 rounded cursor-pointer border-2 overflow-hidden ${ | |
selectedImageIndex === index ? 'border-blue-500' : 'border-gray-300' | |
}`} | |
data-testid={`thumbnail-${index}`} | |
> | |
<img | |
src={image} | |
alt={`${product.title} ${index + 1}`} | |
className="w-full h-full object-cover" | |
/> | |
</button> | |
))} | |
</div> | |
</div> | |
{/* Product Info */} | |
<div> | |
{/* Seller Info */} | |
{product.store && ( | |
<Card className="mb-4 p-3 bg-gray-50"> | |
<CardContent className="p-0"> | |
<div className="flex items-center"> | |
<Store className="text-blue-600 mr-2 h-4 w-4" /> | |
<div> | |
<span className="font-medium text-gray-900" data-testid="store-name"> | |
{product.store.name} | |
</span> | |
<div className="flex items-center mt-1"> | |
<div className="flex text-yellow-400 text-sm"> | |
{[...Array(5)].map((_, i) => ( | |
<Star key={i} className="h-3 w-3 fill-current" /> | |
))} | |
</div> | |
<span className="text-xs text-gray-500 ml-2">(4.9/5)</span> | |
</div> | |
</div> | |
</div> | |
</CardContent> | |
</Card> | |
)} | |
<h1 className="text-2xl font-bold text-gray-900 mb-4" data-testid="product-title"> | |
{product.title} | |
</h1> | |
{/* Rating and Reviews */} | |
<div className="flex items-center mb-4"> | |
<div className="flex text-yellow-400"> | |
{[...Array(5)].map((_, i) => ( | |
<Star key={i} className="h-4 w-4 fill-current" /> | |
))} | |
</div> | |
<span className="text-gray-600 ml-2" data-testid="product-rating">4.9</span> | |
<span className="text-gray-500 ml-2" data-testid="review-count">(124 reviews)</span> | |
</div> | |
{/* Price */} | |
<div className="mb-6"> | |
<div className="flex items-center space-x-3"> | |
<span className="text-3xl font-bold text-gray-900" data-testid="product-price"> | |
₹{parseFloat(product.price).toFixed(2)} | |
</span> | |
{product.originalPrice && ( | |
<span className="text-xl text-gray-500 line-through" data-testid="original-price"> | |
₹{parseFloat(product.originalPrice).toFixed(2)} | |
</span> | |
)} | |
{discountPercentage > 0 && ( | |
<Badge variant="destructive" data-testid="discount-badge"> | |
{discountPercentage}% OFF | |
</Badge> | |
)} | |
</div> | |
</div> | |
{/* Quantity Selector */} | |
<div className="mb-6"> | |
<label className="block text-sm font-medium text-gray-700 mb-2">Quantity</label> | |
<div className="flex items-center space-x-2"> | |
<Button | |
variant="outline" | |
size="icon" | |
onClick={() => setQuantity(Math.max(1, quantity - 1))} | |
disabled={quantity <= 1} | |
data-testid="decrease-quantity" | |
> | |
<Minus className="h-4 w-4" /> | |
</Button> | |
<span className="w-16 text-center border border-gray-300 rounded py-2" data-testid="quantity-value"> | |
{quantity} | |
</span> | |
<Button | |
variant="outline" | |
size="icon" | |
onClick={() => setQuantity(quantity + 1)} | |
disabled={quantity >= product.stock} | |
data-testid="increase-quantity" | |
> | |
<Plus className="h-4 w-4" /> | |
</Button> | |
</div> | |
</div> | |
{/* Stock Info */} | |
<div className="mb-6"> | |
{product.stock > 0 ? ( | |
<p className="text-green-600" data-testid="stock-info"> | |
{product.stock} in stock | |
</p> | |
) : ( | |
<p className="text-red-600" data-testid="out-of-stock"> | |
Out of stock | |
</p> | |
)} | |
</div> | |
{/* Action Buttons */} | |
<div className="flex space-x-4 mb-6"> | |
<Button | |
className="flex-1 bg-blue-600 hover:bg-blue-700" | |
onClick={handleAddToCart} | |
disabled={product.stock === 0} | |
data-testid="add-to-cart-button" | |
> | |
<ShoppingCart className="mr-2 h-4 w-4" /> | |
Add to Cart | |
</Button> | |
<Button | |
variant="outline" | |
className="flex-1" | |
onClick={handleBuyNow} | |
disabled={product.stock === 0} | |
data-testid="buy-now-button" | |
> | |
Buy Now | |
</Button> | |
</div> | |
{/* Product Description */} | |
<div className="border-t pt-6"> | |
<h3 className="font-semibold text-gray-900 mb-3">Product Description</h3> | |
<p className="text-gray-600 leading-relaxed" data-testid="product-description"> | |
{product.description} | |
</p> | |
</div> | |
{/* Category */} | |
{product.category && ( | |
<div className="border-t pt-6 mt-6"> | |
<h3 className="font-semibold text-gray-900 mb-3">Category</h3> | |
<Badge variant="secondary" data-testid="product-category"> | |
{product.category.name} | |
</Badge> | |
</div> | |
)} | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
} | |