Created
April 23, 2026 13:23
-
-
Save parvezpreo/5d1ddf3a61ffcde3ffa26eeba4c9f9b1 to your computer and use it in GitHub Desktop.
React js ProductDetails.jsx codes file
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useState, useEffect, useMemo } from 'react'; | |
| import { useParams, useNavigate } from 'react-router-dom'; | |
| import useSWR from 'swr'; | |
| import ReactMarkdown from 'react-markdown'; | |
| import { | |
| ShoppingCart, Zap, AlertCircle, | |
| Minus, Plus, Phone, MessageCircle, MessageSquare // 🌟 নতুন আইকন ইম্পোর্ট করা হয়েছে | |
| } from 'lucide-react'; | |
| import { useCart } from './CartContext'; | |
| import api, { STORAGE_URL } from './api/api'; | |
| // 🌟 SWR Fetcher | |
| const fetcher = (url) => api.get(url).then((res) => res.data?.data || res.data); | |
| // 🌟 Helper Function (বাইরে রাখা হলো যাতে বারবার রেন্ডার না হয়) | |
| const parseImages = (imgData) => { | |
| try { | |
| if (!imgData) return []; | |
| const parsed = typeof imgData === 'string' ? JSON.parse(imgData) : imgData; | |
| return Array.isArray(parsed) ? parsed : [parsed]; | |
| } catch (e) { | |
| return [imgData]; | |
| } | |
| }; | |
| const getFullImageUrl = (path) => { | |
| if (!path) return 'https://via.placeholder.com/600'; | |
| return path.startsWith('http') ? path : `${STORAGE_URL}${path}`; | |
| }; | |
| const ProductDetails = () => { | |
| const { id } = useParams(); | |
| const navigate = useNavigate(); | |
| const { addToCart } = useCart(); | |
| // 🌟 SWR দিয়ে প্রোডাক্টের ডেটা ফেচ করা হচ্ছে | |
| const { data: rawData, isLoading } = useSWR(`/products/${id}`, fetcher); | |
| // 🌟 SWR দিয়ে গ্লোবাল সেটিংস ফেচ করা হচ্ছে (Contact Button এর জন্য) | |
| const { data: settings } = useSWR('/settings', fetcher); | |
| // Local States | |
| const [selectedColor, setSelectedColor] = useState(null); | |
| const [selectedSize, setSelectedSize] = useState(null); | |
| const [quantity, setQuantity] = useState(1); | |
| const [activeImage, setActiveImage] = useState(0); | |
| // অন্যান্য State এর সাথে এটি যুক্ত করুন | |
| const [isDescOpen, setIsDescOpen] = useState(false); | |
| const [error, setError] = useState(""); | |
| // 🌟 ডেটা ফরম্যাটিং (useMemo দিয়ে ক্যাশ করা হয়েছে পারফরম্যান্সের জন্য) | |
| const product = useMemo(() => { | |
| if (!rawData) return null; | |
| const inventory = rawData.productSizes || rawData.product_sizes || []; | |
| return { | |
| ...rawData, | |
| availableColors: rawData.colors || [], | |
| inventorySizes: inventory, | |
| images: parseImages(rawData.images) | |
| }; | |
| }, [rawData]); | |
| // 🌟 ডাইনামিক টাইটেল এবং পেজ টপে স্ক্রল করা | |
| useEffect(() => { | |
| // Get the dynamic site name from settings, or use a default fallback | |
| const dynamicAppName = settings?.site_name || 'Webit Shop'; | |
| if (product?.name) { | |
| document.title = `${product.name} - ${dynamicAppName}`; | |
| } | |
| window.scrollTo(0, 0); | |
| // Reset states when a new product is loaded | |
| setSelectedColor(null); | |
| setSelectedSize(null); | |
| setQuantity(1); | |
| setActiveImage(0); | |
| setError(""); | |
| }, [product?.name, id, settings?.site_name]); // Added settings?.site_name to dependencies | |
| const getPricingDetails = () => { | |
| if (!product) return { displayPrice: 0, hasOffer: false }; | |
| const regPrice = Number(product.regular_price) || 0; | |
| const globalSalePrice = Number(product.sale_price) || 0; | |
| let activePrice = regPrice; | |
| if (selectedSize && Number(selectedSize.price) > 0) { | |
| activePrice = Number(selectedSize.price); | |
| } | |
| else if (!selectedSize && product.inventorySizes?.some(s => Number(s.price) > 0)) { | |
| const defaultSizePrice = product.inventorySizes.find(s => Number(s.price) > 0); | |
| activePrice = Number(defaultSizePrice.price); | |
| } | |
| else if (globalSalePrice > 0) { | |
| activePrice = globalSalePrice; | |
| } | |
| const hasOffer = activePrice > 0 && activePrice < regPrice; | |
| return { | |
| displayPrice: activePrice > 0 ? activePrice : regPrice, | |
| hasOffer | |
| }; | |
| }; | |
| const handleAction = (isBuyNow = false) => { | |
| const needsColor = product.availableColors?.length > 0; | |
| const needsSize = product.inventorySizes?.length > 0; | |
| if ((needsColor && !selectedColor) || (needsSize && !selectedSize)) { | |
| setError("দয়া করে কালার এবং সাইজ সিলেক্ট করুন।"); | |
| return; | |
| } | |
| if (selectedSize && selectedSize.quantity !== null && selectedSize.quantity < quantity) { | |
| setError(`দুঃখিত! এই সাইজটি মাত্র ${selectedSize.quantity} টি স্টকে আছে।`); | |
| return; | |
| } | |
| setError(""); | |
| const { displayPrice } = getPricingDetails(); | |
| const cartData = { | |
| id: product.id, | |
| name: product.name, | |
| price: displayPrice, | |
| color: selectedColor, | |
| size: selectedSize?.size?.name || selectedSize?.size_id, | |
| quantity: quantity, | |
| image: product.images[0] | |
| }; | |
| addToCart(cartData); | |
| if (isBuyNow) navigate('/checkout'); | |
| }; | |
| if (isLoading) return <div className="min-h-screen flex items-center justify-center">Loading...</div>; | |
| if (!product) return <div className="text-center py-20 font-black text-slate-400">Product Not Found</div>; | |
| const { displayPrice, hasOffer } = getPricingDetails(); | |
| return ( | |
| <div className="bg-white min-h-screen pb-20"> | |
| <div className="max-w-7xl mx-auto px-4 grid grid-cols-1 lg:grid-cols-2 gap-12 pt-10"> | |
| {/* Gallery */} | |
| <div className="space-y-4"> | |
| <div className="aspect-[4/5] rounded-3xl overflow-hidden bg-slate-100 border border-slate-100"> | |
| {/* 🌟 প্রধান ইমেজে Lazy Loading */} | |
| <img src={getFullImageUrl(product.images[activeImage])} loading="lazy" className="w-full h-full object-cover transition-opacity duration-500" alt={product.name} /> | |
| </div> | |
| <div className="flex gap-4 overflow-x-auto pb-2"> | |
| {product.images.map((img, i) => ( | |
| <img key={i} src={getFullImageUrl(img)} loading="lazy" onClick={() => setActiveImage(i)} className={`w-20 h-20 rounded-xl cursor-pointer object-cover border-2 transition-all ${activeImage === i ? 'border-indigo-600' : 'border-transparent opacity-60 hover:opacity-100'}`} alt="" /> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Info */} | |
| <div className="space-y-6"> | |
| <h1 className="text-4xl font-black uppercase italic tracking-tighter">{product.name}</h1> | |
| <div className="flex items-baseline gap-4"> | |
| <span className="text-4xl font-black text-indigo-600"> | |
| ৳{displayPrice} | |
| </span> | |
| {hasOffer && ( | |
| <del className="text-lg text-slate-400 font-bold"> | |
| ৳{product.regular_price} | |
| </del> | |
| )} | |
| </div> | |
| {error && <div className="bg-red-50 text-red-600 p-4 rounded-xl flex items-center gap-2 font-bold text-sm border border-red-100"><AlertCircle size={16}/> {error}</div>} | |
| {/* Color Selection */} | |
| {product.availableColors.length > 0 && ( | |
| <div className="space-y-3"> | |
| <p className="text-[10px] font-black uppercase text-slate-400 tracking-widest">Select Color: <span className="text-slate-900">{selectedColor || "Required"}</span></p> | |
| <div className="flex gap-3"> | |
| {product.availableColors.map((color, i) => ( | |
| <button key={i} onClick={() => setSelectedColor(color.name)} className={`w-10 h-10 rounded-full border-2 transition-all ${selectedColor === color.name ? 'border-indigo-600 p-1 scale-110' : 'border-slate-100'}`}> | |
| <div className="w-full h-full rounded-full shadow-inner" style={{ backgroundColor: color.name.toLowerCase() }} /> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Size Selection */} | |
| {product.inventorySizes.length > 0 && ( | |
| <div className="space-y-3"> | |
| <p className="text-[10px] font-black uppercase text-slate-400 tracking-widest">Select Size: <span className="text-slate-900">{selectedSize?.size?.name || "Required"}</span></p> | |
| <div className="flex flex-wrap gap-3"> | |
| {product.inventorySizes.map((item, i) => { | |
| const sizeName = item.size?.name || `Size-${item.size_id}`; | |
| const isSoldOut = item.quantity !== null && Number(item.quantity) <= 0; | |
| return ( | |
| <button | |
| key={i} | |
| disabled={isSoldOut} | |
| onClick={() => { setSelectedSize(item); setError(""); }} | |
| className={`px-6 py-3 rounded-2xl font-black uppercase text-xs border-2 transition-all | |
| ${isSoldOut ? 'bg-slate-50 text-slate-300 cursor-not-allowed border-slate-50' : | |
| selectedSize?.id === item.id ? 'bg-slate-900 text-white border-slate-900 shadow-xl' : 'bg-white text-slate-600 border-slate-100 hover:border-slate-300'}`} | |
| > | |
| {sizeName} | |
| {item.quantity !== null && item.quantity <= 5 && item.quantity > 0 && ( | |
| <span className="text-[8px] text-orange-500 ml-1">({item.quantity} left)</span> | |
| )} | |
| {isSoldOut && <span className="text-[8px] block opacity-50">Sold Out</span>} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Quantity */} | |
| <div className="space-y-3"> | |
| <p className="text-[10px] font-black uppercase text-slate-400 tracking-widest">Quantity</p> | |
| <div className="flex items-center gap-4 bg-slate-50 w-fit p-1 rounded-2xl border border-slate-100"> | |
| <button onClick={() => setQuantity(Math.max(1, quantity - 1))} className="w-10 h-10 flex items-center justify-center bg-white rounded-xl shadow-sm hover:bg-indigo-600 hover:text-white transition-all"><Minus size={16}/></button> | |
| <span className="w-8 text-center font-black">{quantity}</span> | |
| <button onClick={() => setQuantity(quantity + 1)} className="w-10 h-10 flex items-center justify-center bg-white rounded-xl shadow-sm hover:bg-indigo-600 hover:text-white transition-all"><Plus size={16}/></button> | |
| </div> | |
| </div> | |
| {/* Action Buttons (Add to Cart / Buy Now) */} | |
| <div className="flex gap-4 pt-6"> | |
| <button onClick={() => handleAction(false)} className="flex-1 bg-slate-50 text-slate-900 py-6 rounded-[2rem] font-black uppercase tracking-widest text-xs border border-slate-200 hover:bg-slate-100 transition-all flex items-center justify-center gap-2"> | |
| <ShoppingCart size={18} /> Add to Bag | |
| </button> | |
| <button onClick={() => handleAction(true)} className="flex-[1.5] bg-indigo-600 text-white py-6 rounded-[2rem] font-black uppercase tracking-widest text-xs hover:bg-slate-900 transition-all shadow-xl shadow-indigo-200 flex items-center justify-center gap-2"> | |
| Buy Now <Zap size={18} /> | |
| </button> | |
| </div> | |
| {/* 🌟 NEW: Contact Buttons (WhatsApp, Call, Messenger) */} | |
| <div className="grid grid-cols-3 gap-3 pt-2"> | |
| <a | |
| href={settings?.whatsapp ? `https://wa.me/${settings.whatsapp.replace(/[^0-9]/g, '')}` : '#'} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="flex flex-col items-center justify-center gap-1.5 py-4 bg-[#25D366]/10 text-[#25D366] hover:bg-[#25D366] hover:text-white rounded-2xl transition-all font-black text-[9px] uppercase tracking-widest border border-[#25D366]/20" | |
| > | |
| <MessageCircle size={20} /> | |
| </a> | |
| <a | |
| href={settings?.phone ? `tel:${settings.phone}` : '#'} | |
| className="flex flex-col items-center justify-center gap-1.5 py-4 bg-slate-100 text-slate-700 hover:bg-slate-800 hover:text-white rounded-2xl transition-all font-black text-[9px] uppercase tracking-widest border border-slate-200" | |
| > | |
| <Phone size={20} /> | |
| Call Us | |
| </a> | |
| <a | |
| href={settings?.facebook_messager ? settings.facebook_messager : '#'} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="flex flex-col items-center justify-center gap-1.5 py-4 bg-[#0084FF]/10 text-[#0084FF] hover:bg-[#0084FF] hover:text-white rounded-2xl transition-all font-black text-[9px] uppercase tracking-widest border border-[#0084FF]/20" | |
| > | |
| <MessageSquare size={20} /> | |
| Messenger | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| {/* --- Product Details Section --- */} | |
| <div className="max-w-4xl mx-auto px-4 mt-20 mb-20"> | |
| <div className="border border-slate-100 rounded-[2rem] overflow-hidden bg-white shadow-xl shadow-slate-100/50 transition-all duration-500"> | |
| {/* Header Button */} | |
| <button | |
| onClick={() => setIsDescOpen(!isDescOpen)} | |
| className="w-full flex items-center justify-between p-6 md:p-8 bg-slate-50/50 hover:bg-slate-50 transition-colors group outline-none" | |
| > | |
| <div className="flex items-center gap-4"> | |
| {/* Decorative Line */} | |
| <div className="w-1.5 h-8 rounded-full transition-colors duration-500" style={{ backgroundColor: isDescOpen ? 'var(--accent-color)' : '#cbd5e1' }}></div> | |
| <h2 className="text-2xl md:text-3xl font-black uppercase tracking-tighter text-slate-900 italic"> | |
| Product Details | |
| </h2> | |
| </div> | |
| {/* Plus/Minus Icon */} | |
| <div className="w-12 h-12 rounded-2xl bg-white border border-slate-100 flex items-center justify-center shadow-sm transition-transform duration-500 group-hover:scale-105"> | |
| {isDescOpen ? ( | |
| <Minus size={20} style={{ color: 'var(--accent-color)' }} /> | |
| ) : ( | |
| <Plus size={20} className="text-slate-400" /> | |
| )} | |
| </div> | |
| </button> | |
| {/* Animated Content Body */} | |
| <div | |
| className={`grid transition-all duration-500 ease-in-out ${ | |
| isDescOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0' | |
| }`} | |
| > | |
| <div className="overflow-hidden"> | |
| <div className="p-6 md:p-8 border-t border-slate-100 prose prose-slate max-w-none prose-headings:font-black prose-headings:uppercase prose-headings:tracking-tight prose-a:text-indigo-600"> | |
| <ReactMarkdown>{product.description}</ReactMarkdown> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ProductDetails; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment