'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('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 && (