187 lines
8.4 KiB
TypeScript
187 lines
8.4 KiB
TypeScript
import { useNavigate } from 'react-router-dom';
|
|
import { useState } from 'react';
|
|
import type { Product, TranslatableString } from '../types';
|
|
import { getCategoryImage, formatPrice } from '../types';
|
|
import { Heart, ShoppingBag, Layers } from 'lucide-react';
|
|
import { useWishlistStore } from '../store/wishlistStore';
|
|
import { useCartStore } from '../store/cartStore';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
function resolveStr(v: TranslatableString | null | undefined, lang: string): string {
|
|
if (!v) return ''
|
|
if (typeof v === 'string') return v
|
|
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
|
|
}
|
|
|
|
function resolveOptionValue(val: string, options: any[] | null | undefined, lang: string): string {
|
|
if (!val) return '';
|
|
if (!options || !Array.isArray(options)) return val;
|
|
|
|
for (const option of options) {
|
|
if (!option.values || !Array.isArray(option.values)) continue;
|
|
for (const v of option.values) {
|
|
const baseStr = typeof v === 'string' ? v : (v['th'] || v['en'] || Object.values(v)[0] || '');
|
|
if (baseStr === val) {
|
|
if (typeof v === 'string') return v;
|
|
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? baseStr;
|
|
}
|
|
}
|
|
}
|
|
|
|
return val;
|
|
}
|
|
|
|
interface ProductCardProps {
|
|
product: Product;
|
|
enableQuickShop?: boolean;
|
|
categoryNameOverride?: string;
|
|
}
|
|
|
|
export default function ProductCard({ product, enableQuickShop = false, categoryNameOverride }: ProductCardProps) {
|
|
const navigate = useNavigate();
|
|
const { addItem, removeItem, isInWishlist } = useWishlistStore();
|
|
const { addToCart } = useCartStore();
|
|
const { i18n, t } = useTranslation();
|
|
const isWished = isInWishlist(product.id);
|
|
const [selectedVariant, _setSelectedVariant] = useState(product.variants?.[0] || null);
|
|
const displayName = resolveStr(product.name, i18n.language) || (product.brand ? product.brand.name : '');
|
|
const displayCategoryName = categoryNameOverride || resolveStr(product.category.name, i18n.language);
|
|
const fallbackImage = getCategoryImage(displayCategoryName);
|
|
const images = (product.images?.length ?? 0) > 0
|
|
? product.images!.slice(0, 3).map(img => img.url)
|
|
: [fallbackImage];
|
|
const hasMultipleImages = images.length > 1;
|
|
|
|
const handleWishlistClick = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (isWished) {
|
|
removeItem(product.id);
|
|
} else {
|
|
addItem(product);
|
|
}
|
|
};
|
|
|
|
const handleCardClick = () => {
|
|
navigate(`/products/${product.id}`);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
onClick={handleCardClick}
|
|
className="ui-shell-card ui-shell-card-hover group flex cursor-pointer flex-col overflow-hidden p-2"
|
|
>
|
|
<div className="relative aspect-square overflow-hidden rounded-xl bg-pop-black/5 group/image">
|
|
<div className="ui-chip absolute left-3 top-3 z-20 border-pop-bg bg-pop-bg/90 px-2.5 py-1 text-[10px] text-pop-black shadow-sm backdrop-blur-sm pointer-events-none">
|
|
{displayCategoryName}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleWishlistClick}
|
|
className="absolute right-3 top-3 z-20 rounded-full bg-pop-bg/90 p-2 shadow-sm backdrop-blur-sm transition-transform hover:scale-110 cursor-pointer"
|
|
>
|
|
<Heart
|
|
className={`w-4 h-4 transition-colors ${isWished ? 'fill-pop-pink text-pop-pink' : 'text-pop-black/40 hover:text-pop-pink'}`}
|
|
/>
|
|
</button>
|
|
|
|
{hasMultipleImages ? (
|
|
<>
|
|
{/* Desktop Hover Effect */}
|
|
<div className="hidden md:block absolute inset-0 w-full h-full">
|
|
<img src={images[0]} alt={displayName} className="absolute inset-0 w-full h-full object-cover group-hover/image:opacity-0 transition-opacity duration-700" />
|
|
<img src={images[1]} alt={displayName} className="absolute inset-0 w-full h-full object-cover opacity-0 group-hover/image:opacity-100 group-hover/image:scale-105 transition-all duration-700" />
|
|
</div>
|
|
|
|
{/* Mobile Native Slider */}
|
|
<div className="md:hidden relative w-full h-full" onClick={(e) => e.stopPropagation()}>
|
|
<div className="flex w-full h-full overflow-x-auto snap-x snap-mandatory hide-scrollbar">
|
|
{images.map((img, idx) => (
|
|
<div key={idx} className="relative w-full h-full flex-none snap-center snap-always block" onClick={handleCardClick}>
|
|
<img src={img} alt={`${displayName} ${idx + 1}`} className="absolute inset-0 w-full h-full object-cover" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="absolute bottom-3 left-0 right-0 flex justify-center gap-1.5 z-10 pointer-events-none">
|
|
{images.map((_, idx) => (
|
|
<div key={idx} className="w-1.5 h-1.5 rounded-full bg-white/80 shadow-[0_1px_2px_rgba(0,0,0,0.3)]" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<img
|
|
src={images[0]}
|
|
className="w-full h-full object-cover group-hover/image:scale-105 transition-transform duration-700"
|
|
alt={displayName}
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).style.display = 'none';
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-1 flex-col p-4">
|
|
<div className="mb-2">
|
|
<h3 className="line-clamp-1 text-base font-bold leading-tight text-pop-black transition-colors group-hover:text-pop-pink md:text-lg">
|
|
{displayName}
|
|
</h3>
|
|
<p className="mt-1 line-clamp-1 text-[10px] font-semibold uppercase tracking-widest text-pop-black/50 md:text-xs">
|
|
{displayCategoryName}
|
|
</p>
|
|
</div>
|
|
|
|
{enableQuickShop && product.variants && product.variants.length > 1 && (
|
|
<div className="mb-1 mt-2 flex items-center">
|
|
<span className="inline-flex items-center gap-1 rounded-md bg-pop-black/5 px-2 py-1 text-[10px] font-semibold text-pop-black/70 border border-pop-black/10 shadow-sm transition-colors group-hover:bg-pop-black/10">
|
|
<Layers className="h-3 w-3 opacity-70" />
|
|
{product.variants.length} {t('product.variantsAvailable', 'Options')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-auto flex flex-col gap-2 pt-3">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-black text-pop-pink md:text-lg">
|
|
{formatPrice(selectedVariant?.price ?? product.variants?.[0]?.price ?? 0)}
|
|
</span>
|
|
{!enableQuickShop && (
|
|
<span className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-pop-black transition-transform group-hover:translate-x-1 group-hover:text-pop-pink md:text-xs">
|
|
{t('product.viewDetails')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{enableQuickShop && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (product.variants && product.variants.length > 1) {
|
|
navigate(`/products/${product.id}`);
|
|
} else if (selectedVariant || product.variants?.[0]) {
|
|
const targetVariant = selectedVariant || product.variants[0];
|
|
addToCart({
|
|
productId: product.id,
|
|
variantId: targetVariant.id,
|
|
quantity: 1,
|
|
price: targetVariant.price || 0,
|
|
productName: displayName,
|
|
variantName: [targetVariant.option1, targetVariant.option2, targetVariant.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / '),
|
|
productImage: product.images?.[0]?.url || getCategoryImage(displayCategoryName)
|
|
});
|
|
}
|
|
}}
|
|
className="ui-button-primary w-full gap-1.5 rounded-xl py-2.5 shadow-sm"
|
|
>
|
|
{!(product.variants && product.variants.length > 1) && <ShoppingBag className="w-3.5 h-3.5" />}
|
|
{product.variants && product.variants.length > 1 ? t('product.selectOptions', 'Select Options') : t('cart.addToCart')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|