'use client'; import { useState } from 'react'; import { Image as ImageIcon } from 'lucide-react'; import { CdnImage as Image } from '@/ui/shared/cdn-image'; import type { CartItem as CartItemType } from 'brainerce'; import { getCartItemImage, formatPrice } from 'brainerce'; import { getClient } from '@/core/lib/brainerce'; import { useTranslations } from '@/core/lib/translations'; import { useCurrency } from '@/core/lib/use-currency'; import { LoadingSpinner } from '@/ui/shared/loading-spinner'; import { cn } from '@/core/lib/utils'; import { getErrorCode } from '@/core/lib/add-to-cart-error'; import { OrderCustomizations } from '@/components/account/order-customizations'; interface CartItemProps { item: CartItemType; onUpdate: () => void; className?: string; } /** * Which `cart.*` string each refusal shows. A map rather than a ternary chain: * four outcomes nest badly, and this keeps the copy next to the reason. */ const LINE_ERROR_KEYS = { NOT_ENOUGH_STOCK: 'notEnoughStock', ITEM_UNAVAILABLE: 'itemUnavailable', UPDATE_FAILED: 'quantityUpdateFailed', REMOVE_FAILED: 'removeFailed', } as const; export function CartItem({ item, onUpdate, className }: CartItemProps) { const t = useTranslations('common'); const td = useTranslations('productDetail'); const tc = useTranslations('cart'); const currency = useCurrency(); const [updating, setUpdating] = useState(false); const [removing, setRemoving] = useState(false); // Why the last quantity change or removal did not take. Both calls used to // fail into console.error alone: the row snapped back to the quantity it // already had, or stayed put after Remove, and nothing on screen said why. const [lineError, setLineError] = useState(null); const productName = item.product.name; const imageUrl = getCartItemImage(item); const variantName = item.variant?.name; const unitPrice = parseFloat(item.unitPrice); const lineTotal = unitPrice * item.quantity; // The server decides purchasability, per line. `isAvailable === false` means // this line blocks checkout until it is removed, whether the stock ran out // (a released reservation is the common cause) or the product/variant was // withdrawn from sale. `unavailableReason` separates the two for the label. const isUnavailable = item.isAvailable === false; const unavailableLabel = isUnavailable ? item.unavailableReason === 'OUT_OF_STOCK' ? td('outOfStock') : td('unavailable') : null; async function handleQuantityChange(newQuantity: number) { if (newQuantity < 1 || updating) return; setLineError(null); try { setUpdating(true); const client = getClient(); await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined); onUpdate(); } catch (err) { // Two of these refusals are PERMANENT, and "please try again" would send // the shopper round a loop that cannot succeed: INSUFFICIENT_STOCK (more // than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be // bought at all any more). Both get their own sentence; everything else // is a blip worth one more tap. The 50-line cart cap is NOT a cause here // at all, it only ever refuses a brand new line. Both codes are part of // the documented API error catalogue, not guesses. Keep the log: the // shopper gets the sentence, the developer still needs the detail. const code = getErrorCode(err); setLineError( code === 'INSUFFICIENT_STOCK' ? 'NOT_ENOUGH_STOCK' : code === 'PRODUCT_UNAVAILABLE' ? 'ITEM_UNAVAILABLE' : 'UPDATE_FAILED' ); console.error('Failed to update quantity:', err); } finally { setUpdating(false); } } async function handleRemove() { if (removing) return; setLineError(null); try { setRemoving(true); const client = getClient(); await client.smartRemoveFromCart(item.productId, item.variantId || undefined); onUpdate(); } catch (err) { // Nothing is lost when a remove fails, so the line is still there and // still correct. Say so anyway: a Remove that visibly does nothing reads // as a broken button. setLineError('REMOVE_FAILED'); console.error('Failed to remove item:', err); } finally { setRemoving(false); } } return (
{/* Image */}
{imageUrl ? ( {productName} ) : (
)}
{/* Details */}

{productName}

{/* Availability badge. This line blocks checkout while it shows. */} {unavailableLabel && ( {unavailableLabel} )} {/* Variant name */} {variantName &&

{variantName}

} {/* What the buyer typed / uploaded / picked, keyed by the field's own `key` and carrying the merchant's label. The SDK resolves this on the cart line itself (`CartItem.customizations`, same shape as `OrderItem.customizations`) so no product fetch is needed for the labels. Present only when the line has at least one value set, and the component returns null on an empty map, so a plain product renders exactly as before. */} {item.customizations && } {/* Unit price */}

{formatPrice(unitPrice, { currency }) as string}

{/* Quantity controls */}
{updating ? ( ) : ( item.quantity )}
{/* Why the quantity change or the removal did not take. Keep it inside this line's own column: it describes THIS row, and a shopper with ten rows on screen has to be able to tell which one refused. */} {lineError && (

{tc(LINE_ERROR_KEYS[lineError])}

)}
{/* Line total */}
{formatPrice(lineTotal, { currency }) as string}
); }