Phase 1 Implementation: - Horizontal scrollable thumbnail slider with arrow navigation - Variation selector with auto-image switching - Enhanced buy section with quantity controls - Product tabs (Description, Additional Info, Reviews) - Specifications table from attributes - Responsive design with mobile optimization Features: - Image gallery: Click thumbnails to change main image - Variation selector: Auto-updates price, stock, and image - Stock status: Color-coded indicators (green/red) - Add to cart: Validates variation selection - Breadcrumb navigation - Product meta (SKU, categories) - Wishlist button (UI only) Documentation: - PRODUCT_PAGE_SOP.md: Industry best practices guide - PRODUCT_PAGE_IMPLEMENTATION.md: Implementation plan Admin: - Sortable images with visual dropzone indicators - Dashed borders show drag-and-drop capability - Ring highlight on drag-over - Opacity change when dragging Files changed: - customer-spa/src/pages/Product/index.tsx: Complete rebuild - customer-spa/src/index.css: Add scrollbar-hide utility - admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx: Enhanced dropzone
476 lines
18 KiB
TypeScript
476 lines
18 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { apiClient } from '@/lib/api/client';
|
|
import { useCartStore } from '@/lib/cart/store';
|
|
import { Button } from '@/components/ui/button';
|
|
import Container from '@/components/Layout/Container';
|
|
import { formatPrice } from '@/lib/currency';
|
|
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
|
|
|
export default function Product() {
|
|
const { slug } = useParams<{ slug: string }>();
|
|
const navigate = useNavigate();
|
|
const [quantity, setQuantity] = useState(1);
|
|
const [activeTab, setActiveTab] = useState<'description' | 'additional' | 'reviews'>('description');
|
|
const [selectedImage, setSelectedImage] = useState<string | undefined>();
|
|
const [selectedVariation, setSelectedVariation] = useState<any>(null);
|
|
const [selectedAttributes, setSelectedAttributes] = useState<Record<string, string>>({});
|
|
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
|
const { addItem } = useCartStore();
|
|
|
|
// Fetch product details by slug
|
|
const { data: product, isLoading, error } = useQuery<ProductType | null>({
|
|
queryKey: ['product', slug],
|
|
queryFn: async () => {
|
|
if (!slug) return null;
|
|
|
|
const response = await apiClient.get<ProductsResponse>(apiClient.endpoints.shop.products, {
|
|
slug,
|
|
per_page: 1,
|
|
});
|
|
|
|
if (response && response.products && response.products.length > 0) {
|
|
return response.products[0];
|
|
}
|
|
|
|
return null;
|
|
},
|
|
enabled: !!slug,
|
|
});
|
|
|
|
// Set initial image when product loads
|
|
useEffect(() => {
|
|
if (product && !selectedImage) {
|
|
setSelectedImage(product.image || product.images?.[0]);
|
|
}
|
|
}, [product]);
|
|
|
|
// Find matching variation when attributes change
|
|
useEffect(() => {
|
|
if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) {
|
|
const variation = (product.variations as any[]).find(v => {
|
|
return Object.entries(selectedAttributes).every(([key, value]) => {
|
|
const attrKey = `attribute_${key.toLowerCase()}`;
|
|
return v.attributes[attrKey] === value.toLowerCase();
|
|
});
|
|
});
|
|
setSelectedVariation(variation || null);
|
|
}
|
|
}, [selectedAttributes, product]);
|
|
|
|
// Auto-switch image when variation selected
|
|
useEffect(() => {
|
|
if (selectedVariation && selectedVariation.image) {
|
|
setSelectedImage(selectedVariation.image);
|
|
}
|
|
}, [selectedVariation]);
|
|
|
|
// Scroll thumbnails
|
|
const scrollThumbnails = (direction: 'left' | 'right') => {
|
|
if (thumbnailsRef.current) {
|
|
const scrollAmount = 200;
|
|
thumbnailsRef.current.scrollBy({
|
|
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleAttributeChange = (attributeName: string, value: string) => {
|
|
setSelectedAttributes(prev => ({
|
|
...prev,
|
|
[attributeName]: value
|
|
}));
|
|
};
|
|
|
|
const handleAddToCart = async () => {
|
|
if (!product) return;
|
|
|
|
// Validate variation selection for variable products
|
|
if (product.type === 'variable') {
|
|
if (!selectedVariation) {
|
|
toast.error('Please select all product options');
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await apiClient.post(apiClient.endpoints.cart.add, {
|
|
product_id: product.id,
|
|
quantity,
|
|
variation_id: selectedVariation?.id || 0,
|
|
});
|
|
|
|
addItem({
|
|
key: `${product.id}${selectedVariation ? `-${selectedVariation.id}` : ''}`,
|
|
product_id: product.id,
|
|
name: product.name,
|
|
price: parseFloat(selectedVariation?.price || product.price),
|
|
quantity,
|
|
image: selectedImage || product.image,
|
|
});
|
|
|
|
toast.success(`${product.name} added to cart!`, {
|
|
action: {
|
|
label: 'View Cart',
|
|
onClick: () => navigate('/cart'),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
toast.error('Failed to add to cart');
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Container>
|
|
<div className="animate-pulse max-w-6xl mx-auto py-8">
|
|
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
<div className="grid md:grid-cols-2 gap-8">
|
|
<div className="aspect-square bg-gray-200 rounded"></div>
|
|
<div className="space-y-4">
|
|
<div className="h-8 bg-gray-200 rounded w-3/4"></div>
|
|
<div className="h-6 bg-gray-200 rounded w-1/4"></div>
|
|
<div className="h-24 bg-gray-200 rounded"></div>
|
|
<div className="h-12 bg-gray-200 rounded w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
if (error || !product) {
|
|
return (
|
|
<Container>
|
|
<div className="text-center max-w-2xl mx-auto py-12">
|
|
<h2 className="text-2xl font-bold mb-4">Product Not Found</h2>
|
|
<p className="text-gray-600 mb-6">The product you're looking for doesn't exist.</p>
|
|
<Button onClick={() => navigate('/shop')}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
Back to Shop
|
|
</Button>
|
|
</div>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
const currentPrice = selectedVariation?.price || product.price;
|
|
const regularPrice = selectedVariation?.regular_price || product.regular_price;
|
|
const isOnSale = selectedVariation ? parseFloat(selectedVariation.sale_price || '0') > 0 : product.on_sale;
|
|
const stockStatus = selectedVariation?.in_stock !== undefined ? (selectedVariation.in_stock ? 'instock' : 'outofstock') : product.stock_status;
|
|
|
|
return (
|
|
<Container>
|
|
<div className="max-w-6xl mx-auto py-8">
|
|
{/* Breadcrumb */}
|
|
<nav className="mb-6 text-sm">
|
|
<Link to="/shop" className="text-gray-600 hover:text-gray-900">
|
|
Shop
|
|
</Link>
|
|
<span className="mx-2 text-gray-400">/</span>
|
|
<span className="text-gray-900">{product.name}</span>
|
|
</nav>
|
|
|
|
<div className="grid md:grid-cols-2 gap-8 lg:gap-12">
|
|
{/* Product Images */}
|
|
<div>
|
|
{/* Main Image */}
|
|
<div className="relative w-full aspect-square rounded-lg overflow-hidden bg-gray-100 mb-4">
|
|
{selectedImage ? (
|
|
<img
|
|
src={selectedImage}
|
|
alt={product.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
|
No image
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Thumbnail Slider */}
|
|
{product.images && product.images.length > 1 && (
|
|
<div className="relative">
|
|
{/* Left Arrow */}
|
|
{product.images.length > 4 && (
|
|
<button
|
|
onClick={() => scrollThumbnails('left')}
|
|
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-lg rounded-full p-2 hover:bg-gray-100 transition-colors"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Scrollable Thumbnails */}
|
|
<div
|
|
ref={thumbnailsRef}
|
|
className="flex gap-2 overflow-x-auto scroll-smooth scrollbar-hide px-8"
|
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
>
|
|
{product.images.map((img, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => setSelectedImage(img)}
|
|
className={`flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all ${
|
|
selectedImage === img
|
|
? 'border-primary ring-2 ring-primary ring-offset-2'
|
|
: 'border-gray-200 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
<img
|
|
src={img}
|
|
alt={`${product.name} ${index + 1}`}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right Arrow */}
|
|
{product.images.length > 4 && (
|
|
<button
|
|
onClick={() => scrollThumbnails('right')}
|
|
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 bg-white shadow-lg rounded-full p-2 hover:bg-gray-100 transition-colors"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Product Info */}
|
|
<div>
|
|
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
|
|
|
|
{/* Price */}
|
|
<div className="mb-6">
|
|
{isOnSale && regularPrice ? (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-3xl font-bold text-red-600">
|
|
{formatPrice(currentPrice)}
|
|
</span>
|
|
<span className="text-xl text-gray-400 line-through">
|
|
{formatPrice(regularPrice)}
|
|
</span>
|
|
<span className="bg-red-100 text-red-600 px-2 py-1 rounded text-sm font-semibold">
|
|
SALE
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<span className="text-3xl font-bold">{formatPrice(currentPrice)}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stock Status */}
|
|
<div className="mb-6">
|
|
{stockStatus === 'instock' ? (
|
|
<span className="text-green-600 font-medium flex items-center gap-2">
|
|
<span className="w-2 h-2 bg-green-600 rounded-full"></span>
|
|
In Stock
|
|
</span>
|
|
) : (
|
|
<span className="text-red-600 font-medium flex items-center gap-2">
|
|
<span className="w-2 h-2 bg-red-600 rounded-full"></span>
|
|
Out of Stock
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Short Description */}
|
|
{product.short_description && (
|
|
<div
|
|
className="prose prose-sm mb-6 text-gray-600"
|
|
dangerouslySetInnerHTML={{ __html: product.short_description }}
|
|
/>
|
|
)}
|
|
|
|
{/* Variation Selector */}
|
|
{product.type === 'variable' && product.attributes && product.attributes.length > 0 && (
|
|
<div className="mb-6 space-y-4">
|
|
{product.attributes.map((attr: any, index: number) => (
|
|
attr.variation && (
|
|
<div key={index}>
|
|
<label className="block font-medium mb-2 text-sm">{attr.name}:</label>
|
|
<select
|
|
value={selectedAttributes[attr.name] || ''}
|
|
onChange={(e) => handleAttributeChange(attr.name, e.target.value)}
|
|
className="w-full border border-gray-300 rounded-lg px-4 py-2.5 focus:ring-2 focus:ring-primary focus:border-primary"
|
|
>
|
|
<option value="">Choose {attr.name}</option>
|
|
{attr.options && attr.options.map((option: string, optIndex: number) => (
|
|
<option key={optIndex} value={option}>
|
|
{option}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Quantity & Add to Cart */}
|
|
{stockStatus === 'instock' && (
|
|
<div className="space-y-4">
|
|
{/* Quantity Selector */}
|
|
<div className="flex items-center gap-4">
|
|
<label className="font-medium text-sm">Quantity:</label>
|
|
<div className="flex items-center border border-gray-300 rounded-lg">
|
|
<button
|
|
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
className="p-2.5 hover:bg-gray-100 transition-colors"
|
|
>
|
|
<Minus className="h-4 w-4" />
|
|
</button>
|
|
<input
|
|
type="number"
|
|
value={quantity}
|
|
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
|
className="w-16 text-center border-x border-gray-300 py-2 focus:outline-none"
|
|
min="1"
|
|
/>
|
|
<button
|
|
onClick={() => setQuantity(quantity + 1)}
|
|
className="p-2.5 hover:bg-gray-100 transition-colors"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-3">
|
|
<Button
|
|
onClick={handleAddToCart}
|
|
size="lg"
|
|
className="flex-1 h-12 text-base"
|
|
>
|
|
<ShoppingCart className="mr-2 h-5 w-5" />
|
|
Add to Cart
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="lg"
|
|
className="h-12 px-4"
|
|
>
|
|
<Heart className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Product Meta */}
|
|
<div className="mt-8 pt-8 border-t border-gray-200 space-y-2 text-sm">
|
|
{product.sku && (
|
|
<div className="flex gap-2">
|
|
<span className="text-gray-600">SKU:</span>
|
|
<span className="font-medium">{product.sku}</span>
|
|
</div>
|
|
)}
|
|
{product.categories && product.categories.length > 0 && (
|
|
<div className="flex gap-2">
|
|
<span className="text-gray-600">Categories:</span>
|
|
<span className="font-medium">
|
|
{product.categories.map((cat: any) => cat.name).join(', ')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Product Tabs */}
|
|
<div className="mt-12">
|
|
{/* Tab Headers */}
|
|
<div className="border-b border-gray-200">
|
|
<div className="flex gap-8">
|
|
<button
|
|
onClick={() => setActiveTab('description')}
|
|
className={`pb-4 px-1 border-b-2 font-medium transition-colors ${
|
|
activeTab === 'description'
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
Description
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('additional')}
|
|
className={`pb-4 px-1 border-b-2 font-medium transition-colors ${
|
|
activeTab === 'additional'
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
Additional Information
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('reviews')}
|
|
className={`pb-4 px-1 border-b-2 font-medium transition-colors ${
|
|
activeTab === 'reviews'
|
|
? 'border-primary text-primary'
|
|
: 'border-transparent text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
Reviews
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<div className="py-8">
|
|
{activeTab === 'description' && (
|
|
<div>
|
|
{product.description ? (
|
|
<div
|
|
className="prose prose-sm max-w-none"
|
|
dangerouslySetInnerHTML={{ __html: product.description }}
|
|
/>
|
|
) : (
|
|
<p className="text-gray-600">No description available.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'additional' && (
|
|
<div>
|
|
{product.attributes && product.attributes.length > 0 ? (
|
|
<table className="w-full">
|
|
<tbody>
|
|
{product.attributes.map((attr: any, index: number) => (
|
|
<tr key={index} className="border-b border-gray-200">
|
|
<td className="py-3 pr-4 font-medium text-gray-900 w-1/3">
|
|
{attr.name}
|
|
</td>
|
|
<td className="py-3 text-gray-600">
|
|
{Array.isArray(attr.options) ? attr.options.join(', ') : attr.options}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
<p className="text-gray-600">No additional information available.</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'reviews' && (
|
|
<div>
|
|
<p className="text-gray-600">Reviews coming soon...</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Container>
|
|
);
|
|
}
|