ifixkart-storefront/components/ProductCard.tsx

472 lines
15 KiB
TypeScript
Raw Permalink 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, useMemo } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Heart, Eye, Star, ChartNoAxesColumn } from 'lucide-react';
import { Product } from '../types';
import { getImageUrl, formatCurrency, parseMoney } from '../lib/utils';
import { useCartStore } from '@/store/cartStore';
import { ProductResponse, ProductVariantResponse } from '@/services/api/catalogService';
import BlurHashImage from './ui/BlurHashImage';
import { toast } from 'sonner';
import { addToCompareAndNavigate } from '@/lib/compare';
interface ProductCardProps {
product: Product;
products?: Product[];
onAddToCart?: (product: Product) => void;
onAddToWishlist?: (product: Product) => void;
/** TechShop new-arrivals / grid style */
variant?: 'default' | 'techshop';
}
function getProductResponse(product: any): ProductResponse {
if (product.product_id && Array.isArray(product.variants) && product.variants.length > 0) {
return {
...product,
variants: product.variants.map((v: any) => ({
...v,
price: parseMoney(v.price) || parseMoney(product.price),
compare_price:
v.compare_price != null && v.compare_price !== ''
? parseMoney(v.compare_price)
: null,
cost_price: parseMoney(v.cost_price),
})),
};
}
const realProductId = product.product_id || null;
const realVariantId = product.first_variant_id || product.variant_id || null;
const idStr = String(realProductId || product.id || product.slug || Math.random());
const productId = realProductId || (idStr.startsWith('prod-') ? idStr : `prod-${idStr}`);
const priceVal = parseMoney(product.price);
const variantId = realVariantId || `var-${idStr}`;
const primaryVariant: ProductVariantResponse = {
variant_id: variantId,
product_id: productId,
sku: product.sku || `SKU-${idStr}`,
barcode: null,
price: priceVal,
compare_price: parseMoney(product.oldPrice) || null,
cost_price: priceVal,
low_stock_threshold: 5,
status: 'ACTIVE',
available_stock:
typeof product.totalStock === 'number' ? product.totalStock : undefined,
images: [
{
image_id: `img-var-${idStr}`,
variant_id: variantId,
image_url: product.image || '',
sort_order: 1,
is_primary: true,
},
],
};
return {
product_id: productId,
category_id: product.category || 'uncategorized',
brand_id: product.brand || null,
device_series_id: null,
device_model_id: null,
name: product.name,
slug: product.slug || idStr,
full_path: `/products/${product.slug || idStr}`,
description: product.description || null,
status: 'ACTIVE',
created_at: new Date().toISOString(),
images: [
{
image_id: `img-${idStr}`,
product_id: productId,
image_url: product.image || '',
alt_text: product.name,
sort_order: 1,
is_banner: false,
},
],
variants: [primaryVariant],
rating: product.rating,
review_count: product.reviewsCount,
badge: product.discount ? 'SALE' : null,
};
}
function SaleCountdown({
days = 0,
hours = 0,
minutes = 0,
seconds = 0,
}: {
days?: number;
hours?: number;
minutes?: number;
seconds?: number;
}) {
const [remaining, setRemaining] = useState(
days * 86400 + hours * 3600 + minutes * 60 + seconds
);
useEffect(() => {
setRemaining(days * 86400 + hours * 3600 + minutes * 60 + seconds);
}, [days, hours, minutes, seconds]);
useEffect(() => {
if (remaining <= 0) return;
const t = setInterval(() => setRemaining((s) => (s > 0 ? s - 1 : 0)), 1000);
return () => clearInterval(t);
}, [remaining]);
const d = Math.floor(remaining / 86400);
const h = Math.floor((remaining % 86400) / 3600);
const m = Math.floor((remaining % 3600) / 60);
const s = remaining % 60;
const pad = (n: number) => String(n).padStart(2, '0');
const units = [
{ val: pad(d), label: 'DAYS' },
{ val: pad(h), label: 'HRS' },
{ val: pad(m), label: 'MIN' },
{ val: pad(s), label: 'SEC' },
];
return (
<div className="flex items-center justify-center gap-1 my-2.5">
{units.map((u) => (
<div key={u.label} className="flex flex-col items-center">
<span className="min-w-[32px] h-7 px-1.5 bg-[#fff0f3] rounded-[3px] text-[12px] font-bold text-[#e53935] flex items-center justify-center tabular-nums">
{u.val}
</span>
<span className="text-[8px] font-semibold text-[#f48fb1] uppercase tracking-wide mt-0.5">
{u.label}
</span>
</div>
))}
</div>
);
}
export const ProductCard: React.FC<ProductCardProps> = ({
product,
products,
onAddToCart,
onAddToWishlist,
variant = 'default',
}) => {
const router = useRouter();
const [isHovered, setIsHovered] = useState(false);
const wishlist = useCartStore((state) => state.wishlist) || [];
const compareList = useCartStore((state) => state.compareList) || [];
const prodId = (product as any).product_id || product.id;
const cleanId = String(prodId).replace('prod-', '');
const isWishlisted = wishlist.some(
(item) =>
item.product_id === prodId ||
item.product_id === `prod-${cleanId}` ||
item.product_id === cleanId ||
item.slug === product.slug ||
item.slug === product.id
);
const isCompared = compareList.some(
(item) =>
item.product_id === prodId ||
item.product_id === `prod-${cleanId}` ||
item.product_id === cleanId ||
item.slug === product.slug ||
item.slug === product.id
);
const discountPct = useMemo(() => {
if (product.discount && product.discount > 0) return product.discount;
if (product.oldPrice && product.oldPrice > product.price) {
return Math.round(
((product.oldPrice - product.price) / product.oldPrice) * 100
);
}
return 0;
}, [product.discount, product.oldPrice, product.price]);
const showTimer = Boolean(
product.hasTimer && product.timeRemaining && discountPct > 0
);
const href = `/products/${product.slug || product.id}`;
const isOutOfStock =
typeof product.totalStock === 'number' && product.totalStock <= 0;
const handleCartClick = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isOutOfStock) {
toast.message('This item is currently out of stock');
router.push(href);
return;
}
if (onAddToCart) {
onAddToCart(product);
return;
}
const prodResp = getProductResponse(product);
const primaryVariant = prodResp.variants?.[0];
if (!primaryVariant?.variant_id) {
toast.error('Unable to add this product to cart');
router.push(href);
return;
}
const stock = primaryVariant.available_stock;
if (typeof stock === 'number' && stock <= 0) {
toast.message('This item is currently out of stock', {
description: 'Opening product page for details',
});
router.push(href);
return;
}
const result = await useCartStore
.getState()
.addToCart(prodResp, primaryVariant, 1);
if (result.ok) {
toast.success('Added to cart');
return;
}
if (result.reason === 'out_of_stock') {
toast.message('This item is currently out of stock');
router.push(href);
return;
}
if (result.reason === 'stock_limit') {
toast.error('Not enough stock for the selected quantity');
return;
}
toast.error('Unable to add this product to cart');
};
const handleWishlistClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const prodResp = getProductResponse(product);
useCartStore.getState().toggleWishlist(prodResp);
if (onAddToWishlist) onAddToWishlist(product);
};
const handleCompareClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const prodResp = getProductResponse(product);
void addToCompareAndNavigate(prodResp, (href) => router.push(href));
};
const handleQuickViewClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
window.dispatchEvent(
new CustomEvent('openQuickView', {
detail: {
product,
products: products && products.length > 0 ? products : undefined,
},
})
);
};
const rawImage = typeof product.image === 'string' ? product.image : '';
const rawHoverImage =
typeof product.imageHover === 'string' ? product.imageHover : undefined;
const mainImageUrl = getImageUrl(rawImage);
const hoverImageUrl = rawHoverImage ? getImageUrl(rawHoverImage) : undefined;
const rating = Math.min(5, Math.max(0, Math.round(Number(product.rating) || 5)));
const iconBtn =
'w-8 h-8 rounded-full bg-[#f0f0f0] text-[#444] hover:bg-primary hover:text-white flex items-center justify-center transition-colors cursor-pointer border-0 shadow-sm';
const ctaLabel = isOutOfStock
? 'Out of Stock'
: variant === 'techshop'
? 'Buy Product'
: 'Add To Cart';
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="bg-white p-4 md:p-5 flex flex-col relative font-sans group/card h-full min-h-[380px]"
>
{/* Badges — top right */}
<div className="absolute top-3 right-3 z-10 flex flex-col items-end gap-1">
{isOutOfStock ? (
<span className="bg-[#222] text-white text-[10px] font-bold px-1.5 py-0.5 leading-none uppercase tracking-wide">
Out of Stock
</span>
) : null}
{!isOutOfStock && discountPct > 0 ? (
<span className="bg-primary text-white text-[10px] font-bold px-1.5 py-0.5 leading-none">
-{discountPct}%
</span>
) : null}
</div>
{/* Hover actions — top left */}
<div
className={`absolute left-3 top-3 z-20 flex flex-col gap-2 transition-all duration-200 ${
isHovered
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-1 pointer-events-none'
}`}
>
<button
type="button"
onClick={handleWishlistClick}
className={`${iconBtn} ${isWishlisted ? '!bg-[#ffebee] hover:!bg-[#ffebee]' : ''}`}
title={isWishlisted ? 'Remove from Wishlist' : 'Add to Wishlist'}
aria-label="Add to wishlist"
>
<Heart
size={14}
strokeWidth={1.75}
className={isWishlisted ? 'text-red-500 fill-red-500' : ''}
/>
</button>
<button
type="button"
onClick={handleCompareClick}
className={`${iconBtn} ${isCompared ? '!bg-primary !text-white' : ''}`}
title="Compare"
aria-label="Compare product"
>
<ChartNoAxesColumn size={14} strokeWidth={1.75} />
</button>
<button
type="button"
onClick={handleQuickViewClick}
className={iconBtn}
title="Quick View"
aria-label="Quick view"
>
<Eye size={14} strokeWidth={1.75} />
</button>
</div>
<Link href={href} className="flex flex-col items-center flex-1 select-none">
<div className="relative w-full h-[150px] flex items-center justify-center mb-1">
{mainImageUrl ? (
<>
<BlurHashImage
src={mainImageUrl}
blurHash={(product as any).blur_hash || null}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className={`max-h-full max-w-full object-contain transition-opacity duration-300 ${
isOutOfStock
? 'opacity-45'
: hoverImageUrl && isHovered
? 'opacity-0'
: 'opacity-100'
}`}
/>
{hoverImageUrl && !isOutOfStock && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={hoverImageUrl}
alt={product.name}
className={`absolute inset-0 m-auto max-h-full max-w-full object-contain transition-opacity duration-300 ${
isHovered ? 'opacity-100' : 'opacity-0'
}`}
/>
)}
{isOutOfStock ? (
<span className="absolute inset-x-3 bottom-2 mx-auto w-fit bg-[#222]/90 text-white text-[10px] font-bold uppercase tracking-wide px-2 py-1">
Out of Stock
</span>
) : null}
</>
) : (
<div className="w-20 h-20 bg-gray-50 rounded relative">
{isOutOfStock ? (
<span className="absolute inset-x-0 bottom-0 text-center bg-[#222] text-white text-[9px] font-bold uppercase py-0.5">
Out of Stock
</span>
) : null}
</div>
)}
</div>
{showTimer && product.timeRemaining ? (
<SaleCountdown
days={product.timeRemaining.days}
hours={product.timeRemaining.hours}
minutes={product.timeRemaining.minutes}
seconds={product.timeRemaining.seconds}
/>
) : null}
</Link>
<div className="text-left w-full mt-auto">
<Link href={href}>
<h3
className={`text-[13px] font-medium line-clamp-2 leading-snug transition-colors min-h-[36px] mb-1.5 ${
isHovered ? 'text-primary' : 'text-[#222]'
}`}
title={product.name}
>
{product.name}
</h3>
</Link>
{/* Price: range OR sale + compare */}
<div className="flex items-center gap-2 flex-wrap min-h-[22px]">
{product.priceMax && product.priceMax > product.price ? (
<span className="text-[14px] font-bold text-[#111]">
{formatCurrency(product.price)} {formatCurrency(product.priceMax)}
</span>
) : (
<>
{product.oldPrice && product.oldPrice > product.price ? (
<span className="text-[12px] text-gray-400 line-through">
{formatCurrency(product.oldPrice)}
</span>
) : null}
<span className="text-[14px] font-bold text-[#111]">
{formatCurrency(product.price)}
</span>
</>
)}
</div>
</div>
{/* CTA — Buy Product / Out of Stock */}
<div
className={`w-full overflow-hidden transition-all duration-300 ${
isHovered || variant === 'techshop' || isOutOfStock
? 'h-[42px] mt-3 opacity-100'
: 'h-0 mt-0 opacity-0'
}`}
>
<button
type="button"
onClick={handleCartClick}
className={`swiper-no-swiping w-full h-[40px] text-[13px] font-semibold rounded-md flex items-center justify-center transition-colors cursor-pointer ${
isOutOfStock
? 'bg-[#ececec] text-[#666] hover:bg-[#e3e3e3]'
: 'bg-primary hover:bg-primary-hover text-white'
}`}
>
{ctaLabel}
</button>
</div>
</div>
);
};