'use client'; import { useState } from 'react'; import { Check, Image as ImageIcon, Plus, ShoppingCart, Star } from 'lucide-react'; import { Link, useRouter } from '@/core/lib/navigation'; import { CdnImage as Image } from '@/ui/shared/cdn-image'; import type { Product } from 'brainerce'; import { getProductPriceInfo, formatPrice } from 'brainerce'; import { pickDisplayPrice, pickDisplayPriceRange } from '@/core/lib/display-price'; import { useTranslations } from '@/core/lib/translations'; import { PriceDisplay } from '@/ui/product/price-display'; import { StockBadge } from '@/ui/product/stock-badge'; import { canPurchaseProduct } from '@/core/lib/kit'; import { DiscountBadge } from '@/ui/product/discount-badge'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { useCart } from '@/core/providers/store-provider'; import { useCurrency } from '@/core/lib/use-currency'; import { trackAddToCart } from '@/core/lib/tracking'; import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error'; import { cn } from '@/core/lib/utils'; interface ProductCardProps { product: Product; className?: string; } function VariantPriceRange({ product }: { product: Product }) { const fallbackCurrency = useCurrency(); // ⛔ Region-aware. A range built from priceMin/priceMax alone stays in the // store currency while the single-price card beside it converts, so a // variable product would quote a different currency from its neighbours. const range = pickDisplayPriceRange(product, fallbackCurrency); if (!range) return null; const { min, max, currency } = range; return ( {min === max ? (formatPrice(min, { currency }) as string) : `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`} ); } export function ProductCard({ product, className }: ProductCardProps) { const t = useTranslations('common'); const tp = useTranslations('productDetail'); const tProd = useTranslations('products'); const tr = useTranslations('reviews'); const router = useRouter(); const { refreshCart } = useCart(); const fallbackCurrency = useCurrency(); // FX overlay (PRD §23): prefer the region-converted display values when the // storefront passed regionId to getProducts. Otherwise fall back to the // canonical store-currency basePrice / salePrice. const display = pickDisplayPrice(product, fallbackCurrency); const { price, originalPrice, isOnSale } = getProductPriceInfo(product); const mainImage = product.images?.[0]; const imageUrl = mainImage?.url || null; const slug = product.slug || product.id; const isVariable = product.type === 'VARIABLE'; const [adding, setAdding] = useState(false); const [added, setAdded] = useState(false); const [addError, setAddError] = useState(null); // ⛔ A KIT carries NO `inventory` object — its stock is on `kitAvailable` // (`null` = unlimited, `0` = not sellable). Reading `inventory` here left a // SOLD-OUT kit with an enabled add-to-cart button, because // `undefined?.canPurchase !== false` is `true`. `canPurchaseProduct` reads // the right field per product type, and keeps the `!== false` rule so a store // that tracks no inventory at all can still sell. // A kit also falls through `isVariable` above and adds by `productId` alone, // which is correct — kits take no variantId and no selections. const canPurchase = canPurchaseProduct(product); async function handleAddToCart(e: React.MouseEvent) { e.preventDefault(); e.stopPropagation(); if (isVariable) { router.push(`/products/${slug}`); return; } if (adding || !canPurchase) return; setAddError(null); try { setAdding(true); const { getClient } = await import('@/core/lib/brainerce'); const client = getClient(); await client.smartAddToCart({ productId: product.id, quantity: 1 }); await refreshCart(); // Quick-add from a grid card is a real add_to_cart — without this the // ad platforms only ever see adds made from a product page. Reported in // the DISPLAYED currency (region-converted when the storefront passed a // regionId), because that is what the shopper is actually being charged. trackAddToCart(product, null, 1, display.salePrice ?? display.price, display.currency); setAdded(true); setTimeout(() => setAdded(false), 2000); } catch (err) { // Quick-add used to fail in silence: the button spun, reset, and the // item never appeared. A cart at the 50-line cap is the commonest // cause and the shopper can fix it, so say so on the card. setAddError(toAddToCartError(err)); console.error('Failed to add to cart:', err); } finally { setAdding(false); } } return ( {/* Image — clickable */}
{imageUrl ? ( {mainImage?.alt ) : (
)} {/* Badges */}
{isOnSale && ( {t('sale')} )} {product.isDownloadable && ( {tp('digitalProduct')} )}
{/* Add to cart overlay button */} {(isVariable || canPurchase) && ( )}
{/* Content */}
{/* Categories */} {product.categories && product.categories.length > 0 && (
{product.categories.slice(0, 2).map((cat) => ( {cat.name} ))}
)} {/* Name — clickable */}

{product.name}

{/* Star rating. `avgRating` / `reviewCount` are denormalized rollups the API returns on every product fetch. Auto-hides on a store with no reviews yet: `reviewCount` is 0 (or absent) and the whole block is skipped, so nothing renders and the card keeps its layout. */} {product.reviewCount ? (
) : null} {/* Price */} {isVariable ? ( ) : ( )} {/* Stock */} {/* Quick-add refusal. The card has no other error surface, and without this the shopper taps, the button resets, and nothing appears. */} {addError && (

{addError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}

)}
); }