import React, { useState, useEffect, useRef } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { useQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api/client'; import { useCartStore } from '@/lib/cart/store'; import { useProductSettings } from '@/hooks/useAppearanceSettings'; import { useWishlist } from '@/hooks/useWishlist'; import { useModules } from '@/hooks/useModules'; import { useTheme } from '@/contexts/ThemeContext'; import { Button } from '@/components/ui/button'; import Container from '@/components/Layout/Container'; import { ProductCard } from '@/components/ProductCard'; import { formatPrice } from '@/lib/currency'; import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart, Play } from 'lucide-react'; import { toast } from 'sonner'; import SEOHead from '@/components/SEOHead'; import type { Product as ProductType, ProductsResponse } from '@/types/product'; export default function Product() { const { slug } = useParams<{ slug: string }>(); const navigate = useNavigate(); const { layout, elements, related_products: relatedProductsSettings, reviews: reviewSettings } = useProductSettings(); const [quantity, setQuantity] = useState(1); const [activeTab, setActiveTab] = useState<'description' | 'additional' | 'reviews' | ''>('description'); const [selectedImage, setSelectedImage] = useState(); const [selectedVariation, setSelectedVariation] = useState(null); const [selectedAttributes, setSelectedAttributes] = useState>({}); const thumbnailsRef = useRef(null); const addToCartRef = useRef(null); const [showStickyCTA, setShowStickyCTA] = useState(false); const { addItem } = useCartStore(); const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist } = useWishlist(); const { isEnabled: isModuleEnabled } = useModules(); const { colorMode } = useTheme(); // Apply white background to
in flat mode so the full viewport width is white useEffect(() => { const main = document.querySelector('main'); if (!main) return; if (layout.layout_style === 'flat') { (main as HTMLElement).style.backgroundColor = colorMode === 'dark' ? 'hsl(var(--background))' : '#ffffff'; } else { (main as HTMLElement).style.backgroundColor = ''; } return () => { (main as HTMLElement).style.backgroundColor = ''; }; }, [layout.layout_style, colorMode]); // Fetch product details by slug const { data: product, isLoading, error } = useQuery({ queryKey: ['product', slug], queryFn: async () => { const response = await apiClient.get(`/shop/products?slug=${slug}`); return response.products?.[0] || null; }, enabled: !!slug, }); // Fetch related products const { data: relatedProducts } = useQuery({ queryKey: ['related-products', product?.id], queryFn: async () => { if (!product) return []; try { if (product.related_ids && product.related_ids.length > 0) { const ids = product.related_ids.slice(0, 4).join(','); const response = await apiClient.get(`/shop/products?include=${ids}`); return response.products || []; } const categoryId = product.categories?.[0]?.term_id || product.categories?.[0]?.id; if (categoryId) { const response = await apiClient.get(`/shop/products?category=${categoryId}&per_page=4&exclude=${product.id}`); return response.products || []; } return []; } catch (error) { console.error('Failed to fetch related products:', error); return []; } }, enabled: !!product?.id && elements.related_products, }); // Set initial image when product loads useEffect(() => { if (product && !selectedImage) { setSelectedImage(product.image || product.images?.[0]); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [product]); // AUTO-SELECT FIRST VARIATION (Issue #2 from report) useEffect(() => { if (product?.type === 'variable' && product.attributes && Object.keys(selectedAttributes).length === 0) { const initialAttributes: Record = {}; product.attributes.forEach((attr: any) => { if (attr.variation && attr.options && attr.options.length > 0) { initialAttributes[attr.name] = attr.options[0]; } }); if (Object.keys(initialAttributes).length > 0) { setSelectedAttributes(initialAttributes); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [product]); // Find matching variation when attributes change useEffect(() => { if (product?.type === 'variable' && product.variations && Object.keys(selectedAttributes).length > 0) { let bestMatch: any = null; let highestScore = -1; (product.variations as any[]).forEach(v => { if (!v.attributes) return; let score = 0; const attributesMatch = Object.entries(selectedAttributes).every(([attrName, attrValue]) => { const normalizedSelectedValue = attrValue.toLowerCase().trim(); const attrNameLower = attrName.toLowerCase(); // Find the attribute definition to get the slug const attrDef = product.attributes?.find((a: any) => a.name === attrName); const attrSlug = attrDef?.slug || attrNameLower.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); // Try to find a matching key in the variation attributes let variationValue: string | undefined = undefined; if (`attribute_${attrSlug}` in v.attributes) { variationValue = v.attributes[`attribute_${attrSlug}`]; } else if (`attribute_pa_${attrSlug}` in v.attributes) { variationValue = v.attributes[`attribute_pa_${attrSlug}`]; } else if (`attribute_${attrNameLower}` in v.attributes) { variationValue = v.attributes[`attribute_${attrNameLower}`]; } else if (`attribute_pa_${attrNameLower}` in v.attributes) { variationValue = v.attributes[`attribute_pa_${attrNameLower}`]; } else if (attrNameLower in v.attributes) { variationValue = v.attributes[attrNameLower]; } // If key is undefined/missing in variation, it means "Any" -> Match with score 0 if (variationValue === undefined || variationValue === null) { return true; } // If empty string, it also means "Any" -> Match with score 0 const normalizedVarValue = String(variationValue).toLowerCase().trim(); if (normalizedVarValue === '') { return true; } // Exact match gets a higher score if (normalizedVarValue === normalizedSelectedValue) { score += 1; return true; } // Value mismatch return false; }); if (attributesMatch && score > highestScore) { highestScore = score; bestMatch = v; } }); setSelectedVariation(bestMatch || null); } else if (product?.type !== 'variable') { setSelectedVariation(null); } }, [selectedAttributes, product]); // Auto-switch image when variation selected useEffect(() => { if (selectedVariation && selectedVariation.image) { setSelectedImage(selectedVariation.image); } }, [selectedVariation]); // Build complete image gallery including variation images (BEFORE early returns) // Also includes a video sentinel '__video__' when a video_url is set const allImages = React.useMemo(() => { if (!product) return []; const images = [...(product.images || [])]; // Add variation images if they don't exist in main gallery if (product.type === 'variable' && product.variations) { (product.variations as any[]).forEach(variation => { if (variation.image && !images.includes(variation.image)) { images.push(variation.image); } }); } const filtered = images.filter(img => img && typeof img === 'string' && img.trim() !== ''); // Append a video sentinel so the thumbnail strip shows a video slot if ((product as any).video_url) { filtered.push('__video__'); } return filtered; }, [product]); // Scroll thumbnails const scrollThumbnails = (direction: 'left' | 'right') => { if (thumbnailsRef.current) { const scrollAmount = 200; thumbnailsRef.current.scrollBy({ left: direction === 'left' ? -scrollAmount : scrollAmount, behavior: 'smooth' }); } }; // Intersection Observer for Sticky CTA useEffect(() => { const observer = new IntersectionObserver( ([entry]) => { if (entry.boundingClientRect.y < 0) { setShowStickyCTA(!entry.isIntersecting); } else { setShowStickyCTA(false); } }, { threshold: 0, rootMargin: "-100px 0px 0px 0px" } ); if (addToCartRef.current) { observer.observe(addToCartRef.current); } return () => observer.disconnect(); }, [product]); 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; } } // Construct variation params using keys from the matched variation // but filling in values from user selection (handles "Any" variations with empty values) const variation_params: Record = {}; if (product.type === 'variable' && selectedVariation?.attributes) { // Get keys from the variation's attributes (these are the correct WooCommerce keys) Object.keys(selectedVariation.attributes).forEach(key => { // Key format is like "attribute_7-days-auto-closing-variation-plan" // Extract the slug part after "attribute_" const slug = key.replace(/^attribute_/, ''); // Find the matching user-selected value by attribute name const attrDef = product.attributes?.find((a: any) => a.slug === slug || a.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') === slug ); if (attrDef && selectedAttributes[attrDef.name]) { variation_params[key] = selectedAttributes[attrDef.name]; } else { // Fallback to stored value if no user selection variation_params[key] = selectedVariation.attributes[key]; } }); } try { await apiClient.post(apiClient.endpoints.cart.add, { product_id: product.id, quantity, variation_id: selectedVariation?.id || 0, variation: variation_params, }); addItem({ key: `${product.id}${selectedVariation ? `-${selectedVariation.id}` : ''}`, product_id: product.id, variation_id: selectedVariation?.id, name: product.name, price: parseFloat(selectedVariation?.price || product.price), quantity, image: selectedImage || product.image, virtual: product.virtual, downloadable: product.downloadable, // Use selectedAttributes from state (user's selections) for variable products attributes: product.type === 'variable' && Object.keys(selectedAttributes).length > 0 ? selectedAttributes : undefined, }); 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 (
); } if (error || !product) { return (

Product Not Found

The product you're looking for doesn't exist.

); } // Price calculation - FIXED const currentPrice = selectedVariation?.price || product.price; const regularPrice = selectedVariation?.regular_price || product.regular_price; const isOnSale = regularPrice && currentPrice && parseFloat(currentPrice) < parseFloat(regularPrice); const stockStatus = selectedVariation?.in_stock !== undefined ? (selectedVariation.in_stock ? 'instock' : 'outofstock') : product.stock_status; return ( {/* SEO Meta Tags for Social Sharing */} ]+>/g, '').slice(0, 160) || product.description?.replace(/<[^>]+>/g, '').slice(0, 160)} image={product.image || product.images?.[0]} type="product" product={{ price: currentPrice, currency: (window as any).woonoowCustomer?.currency?.code || 'USD', availability: stockStatus === 'instock' ? 'in stock' : 'out of stock', }} /> {/* Flat: entire Container is bg-white. Card: per-section white cards on gray. */}
{/* Top section: flat = no card wrapper, card = white card */}
{/* Breadcrumb */} {elements.breadcrumbs && ( )}
{/* Product Images */}
{/* Main Image / Video Viewer */}
{selectedImage === '__video__' ? ( // Video player (() => { const vid = (product as any).video_url as string; const vtype = (product as any).video_type as string; if (vtype === 'youtube') { const ytId = vid.match(/(?:v=|youtu\.be\/)([\w-]{11})/)?.[1]; return ytId ? (