472 lines
15 KiB
TypeScript
472 lines
15 KiB
TypeScript
'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>
|
||
);
|
||
};
|