ifixkart-storefront/components/pdp/ProductInteractiveGrid.tsx

659 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React, { useState, useEffect, useRef, useMemo } from 'react';
import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCartStore } from '@/store/cartStore';
import { catalogService, ProductResponse } from '@/services/api/catalogService';
import { formatCurrency, getCatalogPriceInfo, getImageUrl, parseMoney } from '@/lib/utils';
import { addToCompareAndNavigate } from '@/lib/compare';
import {
ShieldCheck,
Truck,
ShoppingCart,
Heart,
Check,
Eye,
Coins,
Share2,
HelpCircle,
GitCompare,
ChevronUp,
ChevronDown
} from 'lucide-react';
interface ProductInteractiveGridProps {
product: ProductResponse;
initialReviewsCount: number;
relatedProducts?: ProductResponse[];
}
const FALLBACK_TECH_IMAGES: string[] = ['/images/placeholder-product.webp'];
function looksLikeBrandId(value?: string | null): boolean {
if (!value) return true;
if (value.length > 18 && /[0-9]/.test(value)) return true;
if (/^[0-9A-Z]{16,}$/i.test(value.replace(/[_-]/g, ''))) return true;
return false;
}
function getSortedImages(images?: any[] | null): any[] {
if (!images || !Array.isArray(images) || images.length === 0) return [];
return [...images].sort((a, b) => {
const aPri = a.is_primary || a.is_banner ? 1 : 0;
const bPri = b.is_primary || b.is_banner ? 1 : 0;
if (aPri !== bPri) return bPri - aPri;
return (a.sort_order ?? 0) - (b.sort_order ?? 0);
});
}
function getPrimaryImageUrl(images?: any[] | null): string | null {
const sorted = getSortedImages(images);
return sorted[0]?.image_url || null;
}
function buildGalleryUrls(images?: any[] | null): string[] {
const sorted = getSortedImages(images);
const urls = sorted
.map((img: any) => getImageUrl(img?.image_url))
.filter((url) => url && url !== '/images/placeholder-product.webp');
return urls.length > 0 ? urls : FALLBACK_TECH_IMAGES;
}
function resolveInitialImage(product: ProductResponse, variantIndex: number, failedImages?: Set<string>): string {
const variant = product.variants?.[variantIndex];
const varImgs = getSortedImages(variant?.images)
.map((i: any) => getImageUrl(i?.image_url))
.filter((u): u is string => Boolean(u) && !failedImages?.has(u!));
if (varImgs.length > 0) return varImgs[0];
const mainImgs = getSortedImages(product.images)
.map((i: any) => getImageUrl(i?.image_url))
.filter((u): u is string => Boolean(u) && !failedImages?.has(u!));
if (mainImgs.length > 0) return mainImgs[0];
return FALLBACK_TECH_IMAGES[0];
}
export const ProductInteractiveGrid: React.FC<ProductInteractiveGridProps> = ({
product,
initialReviewsCount,
relatedProducts = [],
}) => {
const searchParams = useSearchParams();
const router = useRouter();
const [failedImages, setFailedImages] = useState<Set<string>>(new Set());
const [selectedImage, setSelectedImage] = useState<string>(() => resolveInitialImage(product, 0));
const [selectedVariantIndex, setSelectedVariantIndex] = useState(0);
const [quantity, setQuantity] = useState(1);
const [added, setAdded] = useState(false);
const [selections, setSelections] = useState<Record<string, string>>({});
const [showStickyBar, setShowStickyBar] = useState(false);
const [addonQty, setAddonQty] = useState<Record<string, number>>({});
const [brandName, setBrandName] = useState('');
const thumbRailRef = useRef<HTMLDivElement>(null);
// Zoom magnifier lens coordinates state
const [zoomPos, setZoomPos] = useState({ x: 0, y: 0 });
const [isZooming, setIsZooming] = useState(false);
const imageRef = useRef<HTMLDivElement>(null);
const addToCart = useCartStore((state) => state.addToCart);
const toggleWishlist = useCartStore((state) => state.toggleWishlist);
const wishlist = useCartStore((state) => state.wishlist) || [];
const compareList = useCartStore((state) => state.compareList) || [];
const lookupIndex = useRef<{
variantAttrsMap: Map<string, Map<string, string>>;
validValuesByDimension: Map<string, Set<string>>;
} | null>(null);
// Initialize lookup maps and default state on mount/product change
useEffect(() => {
if (!product) return;
const variantAttrsMap = new Map<string, Map<string, string>>();
const validValuesByDimension = new Map<string, Set<string>>();
product.variants.forEach((variant: any) => {
const attrMap = new Map<string, string>();
(variant.attributes || []).forEach((a: any) => {
attrMap.set(a.attribute_code, a.attribute_value);
if (!validValuesByDimension.has(a.attribute_code)) {
validValuesByDimension.set(a.attribute_code, new Set());
}
validValuesByDimension.get(a.attribute_code)!.add(a.attribute_value);
});
variantAttrsMap.set(variant.variant_id, attrMap);
});
lookupIndex.current = { variantAttrsMap, validValuesByDimension };
// Select variant based on URL params or default to first index
const urlVariantId = searchParams.get('variant');
const urlSku = searchParams.get('sku');
let activeIdx = product.variants.findIndex((v: any) => v.variant_id === urlVariantId || v.sku === urlSku);
if (activeIdx === -1) {
activeIdx = 0;
}
setSelectedVariantIndex(activeIdx);
setSelectedImage(resolveInitialImage(product, activeIdx, failedImages));
const initial: Record<string, string> = {};
const activeVar = product.variants[activeIdx];
if (activeVar && activeVar.attributes) {
activeVar.attributes.forEach((a: any) => {
initial[a.attribute_code] = a.attribute_value;
});
}
setSelections(initial);
}, [product, searchParams]);
useEffect(() => {
const named = (product as ProductResponse & { brand_name?: string }).brand_name;
if (named && !looksLikeBrandId(named)) {
setBrandName(named);
return;
}
catalogService.getBrands().then((brands) => {
const match = brands.find((b) => b.brand_id === product.brand_id || b.slug === product.brand_id);
setBrandName(match?.name || 'Gallery');
});
}, [product]);
// Scroll listener for mobile sticky CTA bar
useEffect(() => {
const handleScroll = () => {
if (window.scrollY > 450) {
setShowStickyBar(true);
} else {
setShowStickyBar(false);
}
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!imageRef.current) return;
const { left, top, width, height } = imageRef.current.getBoundingClientRect();
const x = ((e.clientX - left) / width) * 100;
const y = ((e.clientY - top) / height) * 100;
setZoomPos({ x, y });
};
const selectedVariant =
product.variants && product.variants.length > 0
? product.variants[selectedVariantIndex] ||
product.variants.find((v: any) => {
if (!lookupIndex.current) return false;
const attrMap = lookupIndex.current.variantAttrsMap.get(v.variant_id);
if (!attrMap || attrMap.size === 0) return false;
return (
Object.entries(selections).length > 0 &&
Object.entries(selections).every(([k, vVal]) => attrMap.get(k) === vVal)
);
}) ||
product.variants[0]
: null;
useEffect(() => {
if (!selectedVariant) return;
setSelectedImage(resolveInitialImage(product, selectedVariantIndex, failedImages));
}, [selectedVariantIndex, selectedVariant?.variant_id, product, failedImages]);
const availableStock = selectedVariant?.available_stock;
const outOfStock = typeof availableStock === 'number' && availableStock <= 0;
const maxQty = typeof availableStock === 'number' ? Math.max(0, availableStock) : 99;
const isInWishlist = wishlist.some((item) => item.product_id === product.product_id);
const isInCompare = compareList.some((item) => item.product_id === product.product_id);
const galleryImages = useMemo(() => {
const variantImgs = selectedVariant?.images || [];
const validVariantImgs = variantImgs.filter(
(img: any) => !failedImages.has(getImageUrl(img?.image_url))
);
const validProductImgs = (product.images || []).filter(
(img: any) => !failedImages.has(getImageUrl(img?.image_url))
);
const sortedVariant = getSortedImages(validVariantImgs);
const sortedProduct = getSortedImages(validProductImgs);
const rawImages = sortedVariant.length > 0 ? sortedVariant : sortedProduct;
const urls: string[] = [];
const seen = new Set<string>();
rawImages.forEach((img: any) => {
const url = getImageUrl(img?.image_url);
if (url && !failedImages.has(url) && !seen.has(url)) {
seen.add(url);
urls.push(url);
}
});
return urls.length > 0 ? urls : FALLBACK_TECH_IMAGES;
}, [selectedVariant, product.images, failedImages]);
useEffect(() => {
if (galleryImages.length > 0) {
if (!selectedImage || !galleryImages.includes(selectedImage) || failedImages.has(selectedImage)) {
const firstValid = galleryImages.find((img) => !failedImages.has(img)) || galleryImages[0];
setSelectedImage(firstValid);
}
}
}, [galleryImages, selectedImage, failedImages, selectedVariantIndex, selectedVariant?.variant_id]);
const handleAddToCart = () => {
if (selectedVariant) {
const available = selectedVariant.available_stock;
if (typeof available === 'number' && (available <= 0 || quantity > available)) {
return;
}
addToCart(product, selectedVariant, quantity);
Object.entries(addonQty).forEach(([productId, qty]) => {
if (qty <= 0) return;
const addon = relatedProducts.find((p) => p.product_id === productId);
const variant = addon?.variants?.[0];
if (addon && variant) void addToCart(addon, variant, qty);
});
setAdded(true);
window.dispatchEvent(new Event('wishlistUpdated'));
setTimeout(() => setAdded(false), 2500);
}
};
const { price: minPrice, priceMax: maxPrice } = getCatalogPriceInfo(product);
const selectedPrice = parseMoney(selectedVariant?.price);
const selectedCompare = parseMoney(selectedVariant?.compare_price);
const priceLabel = selectedVariant
? formatCurrency(selectedPrice)
: minPrice && maxPrice && minPrice !== maxPrice
? `${formatCurrency(minPrice)} – ${formatCurrency(maxPrice)}`
: formatCurrency(minPrice);
const discountPct =
selectedCompare > selectedPrice
? Math.round(((selectedCompare - selectedPrice) / selectedCompare) * 100)
: 0;
const cleanDesc = (product.description || '')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&amp;/gi, '&')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const featureBullets = cleanDesc
.split(/[.\n]/)
.map((s) => s.trim())
.filter((s) => s.length > 8 && s.length < 60 && !/[<>]/.test(s))
.slice(0, 3);
const fallbackFeatures = ['Certified OEM quality', '12-month replacement warranty', 'Fast dispatch from warehouse'];
const highlights = featureBullets.length > 0 ? featureBullets : fallbackFeatures;
const addonItems = relatedProducts.slice(0, 3);
const scrollThumbs = (dir: -1 | 1) => {
thumbRailRef.current?.scrollBy({ top: dir * 80, behavior: 'smooth' });
};
return (
<>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 lg:gap-10 items-start">
{/* Left Column: Image Gallery */}
<div className="lg:col-span-6 flex flex-col md:flex-row gap-3">
<div className="flex md:flex-col items-center gap-1.5 order-2 md:order-1 flex-shrink-0 justify-center">
<button
type="button"
onClick={() => scrollThumbs(-1)}
className="text-gray-400 hover:text-primary hidden md:flex w-8 h-8 items-center justify-center border border-gray-200 rounded-sm bg-white cursor-pointer"
aria-label="Previous thumbnails"
>
<ChevronUp size={14} />
</button>
<div
ref={thumbRailRef}
className="flex md:flex-col gap-2 overflow-x-auto md:overflow-y-auto no-scrollbar max-w-full md:max-h-[360px]"
>
{galleryImages.map((img, idx) => {
const thumbSrc = failedImages.has(img) ? '/images/placeholder-product.webp' : img;
return (
<button
key={idx}
onClick={() => setSelectedImage(img)}
className={`w-[62px] h-[62px] rounded-sm border p-1 transition-all overflow-hidden flex items-center justify-center bg-white cursor-pointer shrink-0 ${
selectedImage === img ? 'border-primary' : 'border-gray-200 hover:border-gray-300'
}`}
aria-label={`Select thumbnail ${idx + 1}`}
>
<Image
src={thumbSrc}
alt=""
width={62}
height={62}
loading="lazy"
className="max-h-full max-w-full object-contain"
onError={() => setFailedImages((prev) => new Set(prev).add(img))}
/>
</button>
);
})}
</div>
<button
type="button"
onClick={() => scrollThumbs(1)}
className="text-gray-400 hover:text-primary hidden md:flex w-8 h-8 items-center justify-center border border-gray-200 rounded-sm bg-white cursor-pointer"
aria-label="Next thumbnails"
>
<ChevronDown size={14} />
</button>
</div>
<div className="flex-1 order-1 md:order-2 min-w-0">
<div
ref={imageRef}
onMouseEnter={() => setIsZooming(true)}
onMouseLeave={() => setIsZooming(false)}
onMouseMove={handleMouseMove}
className="relative bg-white border border-gray-200 rounded-sm p-6 h-[360px] md:h-[420px] flex items-center justify-center overflow-hidden cursor-crosshair group select-none w-full"
>
{(() => {
const validGallery = galleryImages.filter((img) => !failedImages.has(img));
const effectiveImage =
selectedImage && !failedImages.has(selectedImage) && validGallery.includes(selectedImage)
? selectedImage
: validGallery[0] || FALLBACK_TECH_IMAGES[0];
const mainSrc = failedImages.has(effectiveImage) ? FALLBACK_TECH_IMAGES[0] : effectiveImage;
return (
<Image
src={mainSrc}
alt={product.name}
width={420}
height={420}
priority={true}
className="max-h-full max-w-full object-contain"
onError={() => {
if (effectiveImage && effectiveImage !== FALLBACK_TECH_IMAGES[0]) {
setFailedImages((prev) => new Set(prev).add(effectiveImage));
}
}}
/>
);
})()}
{isZooming && (() => {
const zoomSrc = failedImages.has(selectedImage) ? '/images/placeholder-product.webp' : selectedImage;
return zoomSrc ? (
<div
className="absolute inset-0 pointer-events-none z-30 bg-white"
style={{
backgroundImage: `url(${zoomSrc})`,
backgroundPosition: `${zoomPos.x}% ${zoomPos.y}%`,
backgroundSize: '250%',
backgroundRepeat: 'no-repeat'
}}
/>
) : null;
})()}
</div>
</div>
</div>
{/* Right Column */}
<div className="lg:col-span-6 text-left space-y-3.5">
{discountPct > 0 && (
<span className="inline-flex bg-primary text-white text-[11px] font-bold px-2 py-0.5 rounded-sm">
-{discountPct}%
</span>
)}
<div className="text-[13px] text-gray-600">
Brand:{' '}
<span className="text-primary font-medium">{brandName || 'Gallery'}</span>
</div>
<h1 className="text-[22px] md:text-[26px] font-extrabold text-gray-900 tracking-tight leading-snug font-outfit">
{product.name}
</h1>
<div className="flex flex-wrap items-baseline gap-3">
<span className="text-xl md:text-2xl font-extrabold text-gray-900 font-outfit">{priceLabel}</span>
{selectedCompare > selectedPrice && (
<span className="text-sm md:text-base text-gray-400 line-through font-medium">
{formatCurrency(selectedCompare)}
</span>
)}
</div>
{/* Interactive Variant Attribute Selectors */}
{product.variants && product.variants.length > 0 && (
<div className="space-y-4 pt-2 pb-2 border-t border-b border-gray-100 my-4">
{(() => {
const dimensions = new Map<string, { code: string; label: string; values: Set<string> }>();
(product.variants || []).forEach((variant: any) => {
(variant.attributes || []).forEach((attr: any) => {
const code = String(attr.attribute_code || '').trim();
const val = String(attr.attribute_value || '').trim();
if (!code || !val) return;
if (!dimensions.has(code)) {
const rawName = String(attr.attribute_name || '').trim();
let label = rawName && !looksLikeBrandId(rawName)
? rawName
: code.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const normKey = code.toLowerCase();
const normLabel = label.toLowerCase();
if (normKey === 'qul' || normKey === 'qual' || normLabel === 'qul' || normLabel === 'qual') {
label = 'Quality';
} else if (normKey === 'clr' || normLabel === 'clr') {
label = 'Color';
} else if (normKey === 'str' || normLabel === 'str') {
label = 'Storage';
} else if (normKey === 'ram' || normLabel === 'ram') {
label = 'RAM';
}
dimensions.set(code, { code, label, values: new Set() });
}
dimensions.get(code)!.values.add(val);
});
});
const dims = Array.from(dimensions.values()).map((d) => ({
code: d.code,
label: d.label,
values: Array.from(d.values),
}));
if (dims.length > 0) {
return dims.map((dim) => {
const currentVal = selections[dim.code] || '';
return (
<div key={dim.code} className="space-y-1.5">
<label className="block text-[12px] font-bold uppercase tracking-wider text-gray-700">
{dim.label}: <span className="font-semibold text-primary">{currentVal}</span>
</label>
<div className="flex flex-wrap gap-2">
{dim.values.map((val) => {
const isSelected = currentVal === val;
return (
<button
key={val}
type="button"
onClick={() => {
const next = { ...selections, [dim.code]: val };
setSelections(next);
const match = product.variants.find((v: any) => {
const attrs = (v.attributes || []).reduce((acc: any, a: any) => {
acc[a.attribute_code] = a.attribute_value;
return acc;
}, {});
return Object.entries(next).every(([k, vVal]) => attrs[k] === vVal);
});
if (match) {
const idx = product.variants.findIndex((v: any) => v.variant_id === match.variant_id);
if (idx !== -1) setSelectedVariantIndex(idx);
const varImg = getPrimaryImageUrl(match.images);
if (varImg) {
setSelectedImage(getImageUrl(varImg));
}
}
}}
className={`px-3 py-1.5 rounded text-[12px] font-semibold transition border cursor-pointer ${
isSelected
? 'border-primary bg-primary/10 text-primary font-bold shadow-xs'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
}`}
>
{val}
</button>
);
})}
</div>
</div>
);
});
}
if (product.variants.length > 1) {
return (
<div className="space-y-1.5">
<label className="block text-[12px] font-bold uppercase tracking-wider text-gray-700">
Select Variant: <span className="font-semibold text-primary">{(selectedVariant as any)?.label || selectedVariant?.sku}</span>
</label>
<div className="flex flex-wrap gap-2">
{product.variants.map((v: any, idx: number) => {
const isSelected = selectedVariantIndex === idx;
return (
<button
key={v.variant_id || idx}
type="button"
onClick={() => {
setSelectedVariantIndex(idx);
const varImg = getPrimaryImageUrl(v.images);
if (varImg) {
setSelectedImage(getImageUrl(varImg));
}
}}
className={`px-3 py-1.5 rounded text-[12px] font-semibold transition border cursor-pointer ${
isSelected
? 'border-primary bg-primary/10 text-primary font-bold shadow-xs'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
}`}
>
{v.label || v.sku || `Variant ${idx + 1}`}
</button>
);
})}
</div>
</div>
);
}
return null;
})()}
</div>
)}
<button
disabled={!selectedVariant || outOfStock}
onClick={handleAddToCart}
className={`w-full font-semibold text-sm py-3 px-6 rounded-md transition-colors flex items-center justify-center gap-2 cursor-pointer ${
selectedVariant && !outOfStock
? 'bg-primary hover:bg-primary-hover text-white'
: 'bg-gray-100 text-gray-400 border border-gray-200 cursor-not-allowed'
}`}
>
{added ? <Check className="w-4 h-4" /> : <ShoppingCart size={16} />}
<span>
{!selectedVariant ? 'Unavailable' : outOfStock ? 'Out of Stock' : added ? 'Added to Cart!' : 'Add To Cart'}
</span>
</button>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-[12px] font-medium text-gray-600">
<button
type="button"
onClick={() => void addToCompareAndNavigate(product, (href) => router.push(href))}
className={`flex items-center gap-1.5 cursor-pointer ${isInCompare ? 'text-primary' : 'hover:text-primary'}`}
>
<GitCompare size={14} />
Compare
</button>
<button
onClick={() => toggleWishlist(product)}
className={`flex items-center gap-1.5 cursor-pointer ${isInWishlist ? 'text-primary' : 'hover:text-primary'}`}
>
<Heart size={14} className={isInWishlist ? 'fill-[#e4382f] text-[#e4382f]' : ''} />
Wishlist
</button>
<button
type="button"
onClick={() => {
if (typeof navigator !== 'undefined' && navigator.share) {
void navigator.share({ title: product.name, url: window.location.href });
} else if (typeof navigator !== 'undefined') {
void navigator.clipboard.writeText(window.location.href);
}
}}
className="flex items-center gap-1.5 hover:text-primary cursor-pointer"
>
<Share2 size={14} />
Share
</button>
</div>
</div>
</div>
{/* Sticky Bottom Purchase bar matching iOS Environment indicators */}
{showStickyBar && (
<div className="fixed bottom-0 left-0 right-0 z-[100] bg-white border-t border-gray-200 py-3 px-4 sm:px-8 shadow-2xl transition-all duration-300 flex items-center justify-between gap-4 sticky-purchase-bar">
<div className="flex items-center gap-3 min-w-0">
{(() => {
const barSrc = failedImages.has(selectedImage) ? '/images/placeholder-product.webp' : (selectedImage || '/images/placeholder-product.webp');
return (
<Image
src={barSrc}
alt=""
width={40}
height={40}
loading="lazy"
className="w-10 h-10 object-contain p-1 bg-gray-50 border border-gray-200 rounded-lg flex-shrink-0"
onError={() => {
if (selectedImage) {
setFailedImages((prev) => new Set(prev).add(selectedImage));
}
}}
/>
);
})()}
<p className="text-xs font-bold text-gray-900 truncate max-w-[200px] sm:max-w-[400px]">
{product.name}
</p>
</div>
<div className="flex items-center gap-4">
<span className="text-sm font-extrabold text-gray-900 hidden sm:inline-block">
{selectedVariant ? formatCurrency(selectedPrice) : 'N/A'}
</span>
<button
disabled={!selectedVariant}
onClick={handleAddToCart}
className={`font-extrabold text-xs uppercase tracking-wider py-2.5 px-5 rounded-xl transition-all shadow-sm cursor-pointer whitespace-nowrap ${
selectedVariant
? 'bg-[#0073EC] hover:bg-[#1565C0] text-white'
: 'bg-gray-100 text-gray-450 border border-gray-200 cursor-not-allowed'
}`}
>
{!selectedVariant ? 'Unavailable' : 'Add to Cart'}
</button>
</div>
</div>
)}
</>
);
};