'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 */}
{/* 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 && (