'use client'; import { useState } from 'react'; import { Image as ImageIcon } from 'lucide-react'; import { CdnImage as Image } from '@/ui/shared/cdn-image'; import type { Product, ProductRecommendation } from 'brainerce'; import { formatPrice } from 'brainerce'; import { useCart, useStoreInfo } from '@/core/providers/store-provider'; import { useCurrency } from '@/core/lib/use-currency'; import { useTranslations } from '@/core/lib/translations'; import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; import { cn } from '@/core/lib/utils'; interface FrequentlyBoughtTogetherProps { items: ProductRecommendation[]; currentProduct: Product; className?: string; } // ⛔ STORE CURRENCY THROUGHOUT, deliberately. The cross-sells are // `ProductRecommendation`, a trimmed shape with no displayPrice/displayCurrency, // so there is nothing to convert them to. Converting only `currentProduct` // (which is a full Product and does carry FX fields) would add a euro amount to // two dollar amounts and print the result as one total. One currency wins, and // it has to be the one the cart actually charges. function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number { const sale = item.salePrice ? parseFloat(item.salePrice) : null; const base = parseFloat(item.basePrice); return sale != null && sale < base ? sale : base; } function ProductThumb({ name, imageUrl, price, currency, checked, onToggle, disabled, }: { name: string; imageUrl: string | null; price: number; currency: string; checked: boolean; onToggle?: () => void; disabled?: boolean; }) { return ( ); } export function FrequentlyBoughtTogether({ items, currentProduct, className, }: FrequentlyBoughtTogetherProps) { // Hooks must be called unconditionally and in the same order on every // render — keep all of them above any early `return null` branch. const { storeInfo } = useStoreInfo(); const { refreshCart } = useCart(); const t = useTranslations('productDetail'); const currency = useCurrency(); // ⛔ KITs are dropped, not priced. A recommendation is a `ProductRecommendation` // — an UNRESOLVED list shape, and the recommendations endpoint does not resolve // kits, so `item.basePrice` on a kit is the stored PLACEHOLDER, wrong for every // kit priced SUM / SUM_MINUS_PERCENT. This block sums those numbers into one // "Total" beside a button that adds them all, so a wrong figure here is a // number the shopper commits to. Adding a kit by its own `productId` would in // fact be correct — it is the displayed price that cannot be trusted — so a // kit still sells fine from its own product page, where the API resolves it. // Only show up to 3 cross-sells. const crossSells = items.filter((item) => item.type !== 'KIT').slice(0, 3); const [selected, setSelected] = useState>(() => new Set(crossSells.map((i) => i.id))); const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null; if (crossSells.length === 0) return null; const currentPrice = getEffectivePrice(currentProduct); const currentImage = currentProduct.images?.[0]; const currentImageUrl = currentImage ? typeof currentImage === 'string' ? currentImage : currentImage.url : null; const totalPrice = crossSells .filter((item) => selected.has(item.id)) .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice); const toggleItem = (id: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; async function handleAddAll() { if (adding || selected.size === 0) return; setAddError(null); try { setAdding(true); const { getClient } = await import('@/core/lib/brainerce'); const client = getClient(); const selectedItems = crossSells.filter((item) => selected.has(item.id)); for (const item of selectedItems) { await client.smartAddToCart({ productId: item.id, quantity: 1 }); } await refreshCart(); } catch (err) { // The loop is not transactional: if the cart hits its 50-line cap // part-way through, the earlier items ARE in the cart and the rest are // not. Saying so beats a silent stop the shopper reads as a dead button. setAddError(toAddToCartError(err)); console.error('Failed to add items to cart:', err); } finally { setAdding(false); } } return (

{t('frequentlyBoughtTogether')}

{/* Current product (always included, no checkbox) */} {crossSells.map((item) => { const img = item.images?.[0]; const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null; return (
+ toggleItem(item.id)} />
); })}
{/* Total + Add button */}
{t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
{/* Why the add was refused. Some items may already be in the cart: the loop above adds one at a time and stops at the first refusal. */} {addError && (

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

)}
); }