'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Cart, CartItem, CartRecommendationsResponse, CartUpgradesResponse, CartBundlesResponse, } from 'brainerce'; import { getClient } from '@/core/lib/brainerce'; import { useCart } from '@/core/providers/store-provider'; export interface UseCartPageResult { cart: Cart | null; cartLoading: boolean; refreshCart: () => Promise; itemCount: number; /** Cross-sell recommendations for the current cart contents. */ cartRecs: CartRecommendationsResponse | null; /** Per-item upgrade suggestions keyed by productId. */ upgrades: CartUpgradesResponse | null; /** Bundle offers matching the current cart. */ bundles: CartBundlesResponse | null; /** * True once the stock reservation on this cart has run out and the refreshed * cart still carries the same expired window. Goes back to false on its own * if the server hands back a fresh reservation. */ reservationExpired: boolean; /** Lines the server says cannot be bought right now (`isAvailable === false`). */ unavailableItems: CartItem[]; /** * Cart line ids the shopper has ticked for checkout. Starts as every line * and follows the cart: a newly added line arrives selected, a removed one * drops out. This is a PARTIAL CHECKOUT selection, not a UI nicety — * `createCheckout({ selectedItemIds })` is what the platform honours, and * after payment the unselected lines stay in the still-ACTIVE cart. */ selectedItemIds: string[]; /** Tick / untick one line. */ toggleItemSelected: (itemId: string) => void; /** True when every line is ticked (the default, and the full-cart case). */ allSelected: boolean; /** Tick or untick every line at once. */ setAllSelected: (selected: boolean) => void; /** * Where the proceed-to-checkout action should point. Carries the selection * as `?items=` ONLY when it is a strict subset — a full cart links to a * bare `/checkout`, so the default path is byte-for-byte what it was. */ checkoutHref: string; /** * Lines the shopper actually ticked. Scope the summary and the checkout * gate to these, not to the whole cart. */ selectedItems: CartItem[]; /** * False while the reservation is expired or any SELECTED line is * unavailable. The proceed-to-checkout action must be genuinely disabled on * false, not just styled as disabled. Scoped to the selection on purpose: * an unavailable line the shopper has unticked is not going to the server, * so it must not block a checkout of the rest. */ canProceedToCheckout: boolean; /** * Hand this to ``. It refreshes the * cart once per expiry window: the server, not the client timer, decides * what is still purchasable. */ onReservationExpired: () => void; } /** * Cart-page behavior: the shared cart state (from StoreProvider) plus the * enrichment fetch (recommendations / upgrades / bundles) that runs whenever * the cart contents change. Pure data/behavior — rendering lives in * `ui/cart/cart-view.tsx`. */ export function useCartPage(): UseCartPageResult { const { cart, cartLoading, refreshCart, itemCount } = useCart(); const [cartRecs, setCartRecs] = useState(null); const [upgrades, setUpgrades] = useState(null); const [bundles, setBundles] = useState(null); // Load recommendations, upgrades, and bundles in a single request useEffect(() => { if (!cart?.id || cart.items.length === 0) { setCartRecs(null); setUpgrades(null); setBundles(null); return; } const client = getClient(); client .getCart(cart.id, { include: ['recommendations', 'upgrades', 'bundles'] }) .then((enriched) => { setCartRecs(enriched.recommendations ?? null); setUpgrades(enriched.upgrades ?? null); setBundles(enriched.bundles ?? null); }) .catch(() => {}); }, [cart?.id, cart?.items.length]); // ---- Reservation expiry ---- // // The countdown component only ticks. Expiry is handled here, once, so all // three design variants share one behaviour and a user redesigning the // countdown cannot delete the gate along with the markup. // // Both pieces of state key on the reservation's `expiresAt`, which is the // only stable identity a reservation window has: // - `handledExpiryRef` stops the refresh loop (refresh remounts the // countdown, which would otherwise report the same expiry again). // - comparing `expiredWindow` to the CURRENT `expiresAt` means a server // that renews the reservation clears the expired state by itself. const reservationExpiresAt = cart?.reservation?.expiresAt ?? null; const handledExpiryRef = useRef(null); const [expiredWindow, setExpiredWindow] = useState(null); const onReservationExpired = useCallback(() => { if (!reservationExpiresAt) return; setExpiredWindow(reservationExpiresAt); if (handledExpiryRef.current === reservationExpiresAt) return; handledExpiryRef.current = reservationExpiresAt; // Re-read from the server: it is the source of truth for what survived // the released reservation. `isAvailable` on each line comes back updated. void refreshCart(); }, [reservationExpiresAt, refreshCart]); const reservationExpired = expiredWindow !== null && expiredWindow === reservationExpiresAt && cart?.reservation?.hasReservation === true; // ---- Partial-checkout selection ---- // // Held as a Set of cart-line ids rather than a per-item boolean so it can be // reconciled against the cart in one pass. `null` means "nothing has been // untick'd yet", which is the everything-selected default — distinct from an // empty Set, which is a shopper who deliberately cleared the list. const [deselectedIds, setDeselectedIds] = useState>(() => new Set()); const items = useMemo(() => cart?.items ?? [], [cart?.items]); // Drop ids for lines that no longer exist, so a removed-and-readded product // does not come back invisibly unticked. const liveItemIds = items.map((item) => item.id).join(','); useEffect(() => { setDeselectedIds((prev) => { if (prev.size === 0) return prev; const live = new Set(liveItemIds ? liveItemIds.split(',') : []); const next = new Set([...prev].filter((id) => live.has(id))); return next.size === prev.size ? prev : next; }); }, [liveItemIds]); const selectedItems = items.filter((item) => !deselectedIds.has(item.id)); const selectedItemIds = selectedItems.map((item) => item.id); const allSelected = items.length > 0 && selectedItems.length === items.length; const toggleItemSelected = useCallback((itemId: string) => { setDeselectedIds((prev) => { const next = new Set(prev); if (next.has(itemId)) { next.delete(itemId); } else { next.add(itemId); } return next; }); }, []); const setAllSelected = useCallback( (selected: boolean) => { setDeselectedIds(selected ? new Set() : new Set(items.map((item) => item.id))); }, [items] ); // A full cart keeps the plain `/checkout` URL so the common path is // unchanged; only a strict subset carries the ids. const checkoutHref = allSelected || selectedItemIds.length === 0 ? '/checkout' : `/checkout?items=${encodeURIComponent(selectedItemIds.join(','))}`; // Reported over the whole cart (the banner names every blocked line) but // gated over the selection below. const unavailableItems = items.filter((item) => item.isAvailable === false); const unavailableSelected = selectedItems.filter((item) => item.isAvailable === false); const canProceedToCheckout = !!cart && selectedItems.length > 0 && !reservationExpired && unavailableSelected.length === 0; return { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles, reservationExpired, unavailableItems, selectedItemIds, selectedItems, toggleItemSelected, allSelected, setAllSelected, checkoutHref, canProceedToCheckout, onReservationExpired, }; }