659 lines
28 KiB
TypeScript
659 lines
28 KiB
TypeScript
"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(/</gi, '<')
|
||
.replace(/>/gi, '>')
|
||
.replace(/&/gi, '&')
|
||
.replace(/ /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>
|
||
)}
|
||
</>
|
||
);
|
||
};
|